In this blog post, we will run both our original Django app and the new ASP.NET app as containers in the Oracle Ampere VM, using nginx as a reverse proxy to route external traffic to the correct application. By the end of this post, we will have completed 3 tasks:
- build the Django app as a Docker container and run it on the Oracle Ampere VM alongside the ASP.NET Core app container
- setup subdomains for both containers and serve them with the existing natively-run nginx in the VM
- setup HTTPS access for both
Why containerize all?
Why do we containerize both our ASP.NET Core and Django apps when they can be run natively on Linux? There are multiple benefits:
- Isolation: Each app (Django, ASP.NET) run in their own container with their specific dependencies (e.g., Python version, .NET runtime, libraries). This completely eliminates the dependency hell of trying to install conflicting software on the same OS.
- Portability: Our entire setup is defined in code (
Dockerfile
s and.conf
), so we can take these files to any other machine with Docker and deploy the exact same environment instantly. - Centralized entry point using nginx as reverse proxy: Using a single public-facing entry point to listen on ports 80 (HTTP) and 443 (HTTPS), nginx looks at the incoming request (e.g.,
django.example.com
) and forward it to the correct application container. - Cleaner VM host: We will minimize package installation in the VM so it only handles system admin tasks such as runtimes, firewall, etc. Application-related packages are left in the respective app container.
Rebuild Django app as Docker image
Containerize the Django app
We will build the Docker image in the local dev environment, then deploy to the VM.
If we want to use a custom domain for our Django app
In
settings.py
, editALLOWED_HOSTS
to either include the custom domain likeALLOWED_HOSTS= ['custom domain']
, or pass it in as an environment variable duringdocker run
.DEFAULT_HOSTNAME = os.environ.get('HOSTNAME') ALLOWED_HOSTS = [DEFAULT_HOSTNAME]
By setting this value, we make sure that when we access the container with a custom domain, the Django application will inspect the request header forwarded by nginx to see if it matches this value. Only a matching domain name will be granted access to the app.
Create a
requirements.txt
file that lists the project’s Python dependencies.pip freeze > requirements.txt
Make sure
gunicorn
is included in this file, e.g.,gunicorn==20.1.0
.Create a
.dockerignore
fileTo keep our Docker image small and secure, create a
.dockerignore
file in the project’s root directory to exclude unnecessary files.# .dockerignore # Git .git .gitignore # Docker .dockerignore Dockerfile # Python __pycache__/ *.pyc *.pyo *.pyd .venv/ env/ # Other .env *.sqlite3
Create a
Dockerfile
as a blueprint for the imageIn the project’s root directory, create a file named
Dockerfile
. This file will typically start from an official Python base image, copy the application code, install dependencies fromrequirements.txt
, and define the command to run Gunicorn.# Dockerfile # 1. Use an official Python runtime as a parent image FROM python:3.12-slim # 2. Set environment variables ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 # 3. Set the working directory in the container WORKDIR /app # 4. Copy the requirements file and install dependencies COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt # 5. Copy the project code into the container COPY . /app/ # 6. Expose the port Gunicorn will run on EXPOSE 8000 # 7. Run Gunicorn when the container launches CMD ["gunicorn", "--bind", "0.0.0.0:8000", "your_project_name.wsgi"]
Remember to replace
your_project_name
with the actual name of your Django project’s WSGI file.Note that we have to use a Python base image version later than 3.9 as Gunicorn requires it, otherwise we will get
ERROR: Ignored the following versions that require a different python version: 5.0 Requires-Python >=3.10
duringdocker build
. You can edit the image version at the 1st line for theFROM
instruction.Build the Docker Image
Note: installand run Docker Desktop before invoking the Docker CLI
From the project’s root directory, run the
docker buildx
command.docker buildx build --platform linux/arm64 -t helloworld .
-t
: tags it with the namehelloworld
--platform linux/arm64
: build the image to be run on the arm64 arch (we are using the Oracle Ampere Compute instance to run our VM).
at the end tells Docker to use the current directory as the build context.
Obtain image in VM
Once the image is built on the dev machine, we need to get it to our Oracle VM.
Via a container registry
We can push the image to a container registry like Docker Hub, GitHub Container Registry, or Oracle Cloud Infrastructure Registry (OCIR). This makes maintenance of multiple versions and arch easy.
# Tag the image for the registry
docker tag helloworld your-registry-username/helloworld
# Push the image
docker push your-registry-username/helloworld
We can then pull from the VM by SSH into our OCI VM and pull the image from the registry.
docker pull your-registry-username/helloworld
Save and copy the image
For one time use, we can also use scp
to transfer the image to the VM.
Save the local image to a tar file.
docker save -o helloworld.tar helloworld
In local machine, use
scp
to copy the tar file to the VM.scp -i <PATH_TO_PRIVATE_KEY> helloworld.tar opc@VM_PUBLIC_IP:/home/opc/
SSH into the VM and load the image from the tar file.
docker load -i helloworld.tar
Run the Django container
Stop the old Gunicorn service
Since Django will now run in a container, we don’t need the
gunicorn
service anymore.sudo systemctl stop gunicorn sudo systemctl disable gunicorn
Run the Django container
First, discover which port the image listens to:
docker image inspect helloworld | grep -A2 ExposedPorts
We can then use the exposed port in the following:
docker run -e HOSTNAME="django.example.com" -d -p 7777:8000 --name django-app helloworld
-e HOSTNAME="django.example.com"
: Specify the value to pass toALLOWED_HOSTS
insettings.py
in the Django app for our custom domain.-d
: Runs the container in detached mode in the background.-p 7777:8000
: Maps host VM’s port7777
to the container’s port8000
where Gunicorn will be listening at. This makes the Django app container accessible publicly via http://VM_PUBLIC_IP:7777.- The host-port (
7777
in this case) is the port that nginx will connect to on the VM. - The container-port (
8000
in this case) is the port that Django application is listening on inside the container.
- The host-port (
--name django-app
: Gives your container a memorable name (django-app
). This makes it easier to manage later.helloworld
: The name of the Docker image.
Now both our Django and ASP.NET Core apps are fully containerized and up running! We will be able to access them from the public by http://VM_PUBLIC_IP:PORT
Configure custom domain
Remembering IP and port numbers are tedious and non-practical. Let’s setup unique domain names for our apps. This involves modifying our DNS records in our DNS registrar, and setting up reverse proxy in the VM to filter traffic based on domain name.
Create new DNS records
To access the Docker containers with unique domain names, we have to edit the DNS configuration at our DNS registrar.
We will create A records for both apps that both point to the Oracle VM’s public IP address.
Subdomain | Type | Value | TTL |
---|---|---|---|
django.example.com | A | <VM-public-IP> | 300 s |
asp.example.com | A | <VM-public-IP> | 300 s |
Note that DNS changes can take some time to propagate.
Setup reverse proxy
Next, we will set up a reverse proxy. This is a server that sits in front of our applications, accepts requests from the internet, and forwards them to the correct internal container based on the domain name. The most common tool for this is nginx, which is already running natively in the VM.
We will create new nginx configuration files for both the Django and ASP.NET Core apps. This is a modular file that nginx will load from the /etc/nginx/conf.d/ directory. Each file will contain a server block to route requests from a custom domain to the app.
Let’s take care of Django app first. Open the nginx configuration file:
sudo nano /etc/nginx/conf.d/django.conf
We will use the following settings:
server {
listen 80;
server_name django.example.com;
location / {
proxy_pass http://127.0.0.1:7777;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
listen 80;
: nginx will listen for incoming HTTP requests from the public internet, on the VM host machine’s port 80. This is the standard port for unencrypted HTTP traffic. When a request is made to http://django.example.com , the request is sent to the server’s IP address on port 80. By setting nginx to listen on this port, we ensure that it intercepts all standard web traffic for the domain.server_name CUSTOM_DOMAIN;
: This block is activated when the incoming request’s Host header matches this domain. nginx is designed to handle multiple apps from different configuration files. It uses theserver_name
directive within each server block to decide which block should handle an incoming request based on the domain name in the request’s header.If we are setting it to a root domain, we can include both the root domain and its
www
subdomain names in a singleserver_name
directive. nginx will then use this singleserver
block for all requests coming to either of those domains.proxy_pass http://127.0.0.1:7777;
: This tells nginx where to forward the traffic it receives.127.0.0.1
is the loopback address, which refers to the VM itself.- Port
7777
is the port on the VM that we have mapped to the Django container. This means that nginx forwards this traffic to localhost at port7777
on the host machine. Docker then takes over and sends it to port8000
within your container.
Here’s the step by step network traffic workflow:
- A request from a user arrives at the VM’s public IP on port 80.
- nginx, listening on port 80, receives the request.
- nginx checks the request’s Host header, and searches all the
.conf
files for a matchingserver_name
value. When a match is found, nginx forwards that request internally to the port specified inproxy_pass
. In our case, it is port7777
on the host machine. - Docker runtime receives the request on host machine’s port
7777
. Remembering that when we run the container, we mapped the port as-p 7777:8000
. So Docker runtime routes it to the Django container’s internal port 8000, where Gunicorn is listening. - Gunicorn in the container receives the request and processes it.
Public request -> django.example.com:80 -> nginx -> 127.0.0.1:7777 ->
Docker runtime -> Container's internal port 8000 -> Gunicorn serves Django app
We have setup Django container for nginx. Let’s do the same thing for the ASP.NET Core container.
Finally, we will restart or reload the nginx service to apply the changes. nginx only reads its configuration files when it starts up or when it receives a signal to reload its configuration.
It’s better to use the reload command than restart because it applies the changes without dropping any active connections. The reload command first checks the configuration files for syntax errors and, if the check passes, it loads the new configuration gracefully.
sudo systemctl reload nginx
Now we can hit the custom domains with http://django.example.com and http://asp.example.com to reach our containers.
Accept HTTPS traffic
To accept secure traffic, we have to setup firewall rules and install SSL/TLS certificates on our nginx server.
Enable ingress and firewall rules
We need to set up two separate firewall rules to allow HTTPS traffic to reach our nginx server. One is at the OCI (Oracle Cloud Infrastructure) network level, and the other is on the VM’s operating system.
OCI Network Security List
The OCI Security List acts as a virtual firewall for the subnet our VM is in. It controls traffic to and from all instances within that subnet.
We need to add an Ingress Rule to the security list to allow incoming HTTPS traffic.
- In the OCI Console, navigate to Virtual Cloud Network (VCN).
- Select your Subnet.
- Click on the Security List associated with the subnet.
- Click Add Ingress Rule and enter the following details:
- Source CIDR:
0.0.0.0/0
(Allows traffic from any IP address). - IP Protocol:
TCP
. - Source Port Range: (leave as All).
- Destination Port Range:
443
(The standard port for HTTPS).^3^
- Source CIDR:
- Click Add Ingress Rule to save the changes.
Oracle Linux VM Firewall
We have to also configure the VM’s built-in firewall, firewalld
to allow incoming traffic on port 443 (HTTPS) to the VM’s public IP, and ensure the rule persists after a reboot.
# Add a rule to allow the HTTPS service
sudo firewall-cmd --permanent --add-service=https
# Reload the firewall to apply the new rule
sudo firewall-cmd --reload
Obtain certificate
To enable HTTPS, we’ll also need to install an SSL/TLS certificate on our nginx server. The easiest way to do this for free is by using Certbot and Let’s Encrypt. Certbot is a tool that automates the process of obtaining and installing certificates.
Install Certbot
We have to install the Certbot package and its nginx plugin, which allows Certbot to automatically configure nginx for us.
Note that for Oracle Linux,
certbot
andpython3-certbot-nginx
packages aren’t in the default repositories. They are typically located in the EPEL (Extra Packages for Enterprise Linux) repository, and EPEL is typically disabled. To fix this, we need to explicitly enable the repository.Let’s confirm the EPEL repository is present but disabled by running
sudo dnf repolist all
to see a full list of all repositories, both enabled and disabled.Check for an entry for EPEL with a status of
disabled
. The repository ID will be in the first column, and it will look something likeol9_developer_EPEL
.Once we’ve found the correct repository ID, run the following command to enable it. Be sure to replace
<repository-id>
with the actual ID from the previous output. For example:sudo dnf config-manager --set-enabled ol9_developer_EPEL
We then run
sudo dnf install -y epel-release
with-y
flag to confirm the installation.Finally, install Certbot and all its necessary dependencies by
sudo dnf install certbot python3-certbot-nginx
Obtain the SSL Certificate
Once Certbot is installed, we can run the command to obtain and install the certificates for both of your subdomains.
sudo certbot --nginx -d django.example.com -d asp.example.com
--nginx
: tells Certbot to use the nginx plugin to configure the web server.-d
: specify the domain names for which we want to obtain a certificate. We can list multiple domains by using several-d
flags.note: if we are registering for a root domain, specify both the root domain and the
www
subdomain in the samecertbot
command. Certbot will create a single Subject Alternative Name (SAN) certificate, which is a modern type of certificate that works for multiple hostnames. It will also update your NGINX configuration to use this certificate and redirect HTTP to HTTPS for bothexample.com
andwww.example.com
.
Certbot will then do the following:
- ask us to enter an email address for important renewal notices.
- ask us to agree to the Let’s Encrypt Terms of Service.
- automatically verify that we own the domains by creating a temporary file on the server.
- obtain the SSL certificates from Let’s Encrypt.
- automatically modify each nginx configuration file to add a new server block to enable HTTPS on port 443, and add redirects from HTTP (port 80) to HTTPS.
- add an HTTP-to-HTTPS redirect for all traffic to the 2 custom domains.
- reload nginx to apply the new configurations.
Verify the HTTPS configuration
After Certbot completed its process, the nginx configuration files (django.conf and asp.conf in
/etc/nginx/conf.d/
) will be automatically updated.Certbot adds a new
listen
directive for port 443 and specifies the paths to the certificate files. It also adds a redirect for all HTTP traffic.For example, the django.conf might look something like this:
server { listen 80; server_name django.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name django.example.com; ssl_certificate /etc/letsencrypt/live/django.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/django.example.com/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location / { proxy_pass http://127.0.0.1:7777; # ... other proxy headers } }
Configure automatic renewal
Let’s Encrypt certificates are only valid for 90 days. Certbot automatically sets up a
systemd
timer or cron job that runs twice a day to renew the certificates before they expire. This process checks all installed certificates and renews any that are within 30 days of expiration. This ensures that the certificate is always valid and our website remains secure without any intervention on our part.We can manually test the automatic renewal process to make sure it’s working correctly. This command simulates the renewal without actually saving new files:
sudo certbot renew --dry-run
If the command completes successfully, we’ll see output indicating that the dry run succeeded for all our domains, confirming the automatic renewal is properly configured.
We can now test our apps by navigating to https://asp.example.com in the browser. We should see a padlock icon, indicating a secure connection.
Note that after registering our email adress and setting up Certbot for the 1st time, minting subsequent certs will be a brisk as sudo certbot --nginx -d yourdomain.com
will automatically obtain, save and deploy certs to nginx.
Monitor container usage
Now that we have both apps deployed and running in their own domains with HTTPS access, let’s monitor their typical resource usage to avoid overage in OCI. The simplest way to do this is with the command docker stats
. It gives you a live view of the resource consumption for all running containers. Just hit the apps from a browser and watch the command window update with real time CPU %, memory usage, network I/O and block I/O usagedata. You will also see MEM USAGE / LIMIT
showing the total memory space the Docker runtime can access in the VM.
Wrap-up
To summarize, we can perform these steps to add a future container xyz with custom domain xyz.example.com:
Create new DNS A record → VM IP.
Run container with port mapping (
-p host-port:container-port
):docker run -d --name xyz -p 7780:8000 xyz-image
Create per-site config (xyz.conf) using template, update:
server_name xyz.example.com
proxy_pass http://127.0.0.1:7780;
Test & reload Nginx
Setup SSL cert for HTTPS:
sudo certbot --nginx -d xyz.example.com
The process is streamlined with the following benefits:
- One external facing IP, many subdomains, each with its own container.
- Nginx handles routing, TLS, and host separation.
- New apps take minutes to add.
- Certs auto‑renew with no downtime.
Appendix: Troubleshooting
If we get 502 or 400 errors using our custom domain, we can run a few checks.
400 Bad Request
The 400 Bad Request
error is not an nginx issue. It’s due to the container’s application server explicitly rejecting the request. In a reverse proxy setup, this almost always means there is a problem with the HTTP headers that nginx is forwarding to the container.
Web frameworks often have a security feature that checks the Host
header to prevent certain types of attacks. If the Host
header in the incoming request doesn’t match a pre-approved list, the application will reject it with a 400 Bad Request
.
- Django: The Django framework uses
ALLOWED_HOSTS
in itssettings.py
file. If the incomingHost
header contains a domain not in this list, Django will immediately reject the request. - ASP.NET Core: The Kestrel server is often configured to listen on
0.0.0.0
or a similar binding that accepts requests from any host. This makes it a more flexible default than Django’s strict host checking.
Remember in our Django app, we have these lines in settings.py
:
DEFAULT_HOSTNAME = os.environ.get('HOSTNAME')
ALLOWED_HOSTS = [DEFAULT_HOSTNAME]
Here’s what happens:
- When a request is sent to nginx for
django.example.com
, it checks all.conf
server blocks with the right domain name. Once a match is found, it usesproxy_pass
to find the forward port, andproxy_set_header
to construct a host header to pass on. - The default setting for an application’s .conf is
proxy_set_header Host $host
- nginx uses this to construct a
Host
header with the value of the incoming domaindjango.example.com
and forwards this to the Django container. - By default, inside a Docker container, there is usually an environment variable named
HOSTNAME
set by Docker itself to the container’s internal hostname (it’s container ID). If we do not explicitly set this environment variable duringdocker run
, this internal value gets picked up by Django and passed toALLOWED_HOST
.You can verify this with
docker ps
, note the django container’s CONTAINER ID. Then rundocker exec django-app printenv HOSTNAME
to see the actual environment variable being passed. - Therefore, the
ALLOWED_HOSTS
list our Django app resolves is['container_id']
. - Because
HOST: django.example.com
is not in theALLOWED_HOSTS
list, Django rejects the request with a “400 Bad Request” error.
Even if we don’t have environment variable setup, we might get this error if we forget to the set ALLOWED_HOST
in settings.py
as appropriate.
502 Bad Gateway
A “502 Bad Gateway” error means nginx is receiving the request but is unable to connect to the upstream server, which in this case is our app container. The most likely culprits are a mismatch in ports, container status, or a networking problem on the host.
Check container status
First check the container’s status.
sudo docker ps -a
This command shows all containers, including stopped ones. Make sure our container has a status of Up
and is not Exited
or Stopped
.
Manually test connection
We can bypass nginx and test the connection from the VM directly to the container’s mapped port.
curl http://127.0.0.1:7777
If this command returns a valid response, it confirms that the container is reachable on the host, and the issue is with how nginx is trying to connect to it.
If it fails with a “Connection refused” or hangs, the problem is with the container itself or its port mapping.
Check nginx config syntax
Verify the syntax of configuration files without restarting the service by sudo nginx -t
If there are any errors, nginx will tell you exactly where they are. If the test is successful, you’ll see a message like: nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
.
Check nginx error logs
The nginx error logs will give us the precise reason for the 502 error. Run this command and then try to access your custom domain again to see the live error message.
sudo tail -f /var/log/nginx/error.log
Look for messages that mention connect() failed
or upstream prematurely closed connection.
The error will usually specify the exact IP and port nginx tried to connect to. We can then use this to troubleshoot any port mistach in our configuration file.
Verify port listening on host
If we’ve configured nginx to connect to port 7777
on the local machine (127.0.0.1
) with proxy_pass http://127.0.0.1:7777
, we can confirm that something is actually listening on that port by
sudo netstat -tulpn | grep 7777
We should see a line that indicates a process (the Docker container) is listening on 0.0.0.0:7777
or 127.0.0.1:7777
.
If we get no output, it means the app container is not properly exposing its port to the host, or it’s not running.
Comments