Docker application hosting on Azure with Nginx Proxy Manager

A practical single-VM Docker hosting pattern for multiple websites, APIs, dashboards and internal tools using Ubuntu, Docker Compose, Nginx Proxy Manager and Azure network controls.

If you need to host a few websites, dashboards, APIs or internal tools, it is tempting to create a new VM for every app.

That works for a while. Then the estate becomes hard to track: too many servers, too many certificates, too many places to check logs, and no consistent deployment pattern.

This guide shows a simpler middle ground:

One Ubuntu VM.
Multiple Docker apps.
One reverse proxy.
One app drive.
Controlled access through Azure.

It is not Kubernetes. It is not trying to be Azure App Service or Azure Container Apps. It is a practical single-server hosting pattern for lightweight workloads that still need isolation, HTTPS, and a clean operational model.

The basic idea

The platform uses one secured Ubuntu VM as a shared Docker host.

Each application gets its own folder and its own docker-compose.yml. The app runs in its own container or Compose stack. Public traffic reaches the VM through Azure network controls, then Nginx Proxy Manager routes each domain to the correct container.

Typical apps might include:

- Static HTML/CSS/JavaScript sites
- React build output
- Node.js apps
- Python APIs
- Streamlit or Dash dashboards
- Internal tools
- Lightweight prototypes

The standard pattern is:

domain -> Azure network rules -> Nginx Proxy Manager -> Docker container

Why this is useful

The value is not that Docker is fashionable. The value is operational simplicity.

Instead of giving every small app its own VM, you can run several isolated apps on one host while keeping a predictable folder structure and a single HTTPS entry point.

Each app has:

- Its own folder
- Its own Compose file
- Its own container name
- Its own logs or data folders, where needed
- Its own proxy host mapping

For example:

nicelink.company.com     -> nicelink-web:80
dashboard.company.com    -> dashboard-web:3000
api.company.com          -> api-service:8000

Nginx Proxy Manager decides where traffic goes based on the domain name. The app containers do not normally expose their own public ports.

How traffic flows

At a high level, the flow looks like this:

User / Browser
  -> DNS record
  -> Azure Firewall or NSG
  -> Ubuntu Docker VM
  -> Nginx Proxy Manager
  -> Docker proxy network
  -> App container
Azure cloud routing flow from public users through Azure Firewall or NSG to Nginx Proxy Manager and multiple Docker containers
Public HTTPS traffic enters through Azure network controls, reaches Nginx Proxy Manager, and is routed to the correct container on the shared Docker proxy network.

The common exposed ports are:

PortPurposeExposure
22SSH administrationRestricted to admin or operator IPs
80HTTP and certificate validationPublic only when needed
443HTTPS application trafficPublic
81Nginx Proxy Manager UIRestricted to trusted IPs

Port 81 should not be casually public. It is an admin interface and should be treated like one.

The main building blocks

The platform is made from a small number of boring components, which is part of the point.

ComponentRole
Azure Firewall / NSGControls inbound access before traffic reaches the VM
Ubuntu ServerThe base operating system
Docker EngineRuns the application containers
Docker ComposeDefines each app stack
Nginx Proxy ManagerRoutes domains and manages SSL certificates
/appdriveStores app and proxy data away from the OS disk
Fail2banHelps reduce repeated SSH login attempts
SSH keys and Linux groupsControl operator access

This is enough for many small and medium internal workloads without introducing a full orchestration layer.

Keep apps out of home folders

One important design choice is to keep application files away from user home directories.

Do not make production apps live here:

/home/<username>

Use a dedicated application drive instead:

/appdrive

A simple structure looks like this:

/appdrive
|-- docker
|   |-- apps
|   |   `-- nicelink
|   `-- proxy
|       `-- nginx-proxy-manager
|-- logs
`-- transfer

Application folders then live under:

/appdrive/docker/apps/<app-name>

This makes the platform easier to back up, review, move, and troubleshoot.

How one app is normally laid out

For a static or React app, the folder may be very small:

/appdrive/docker/apps/my-static-app
|-- docker-compose.yml
|-- html
`-- logs

For a Node.js app, the folder may include source code and a Dockerfile:

/appdrive/docker/apps/my-node-app
|-- docker-compose.yml
|-- Dockerfile
|-- package.json
`-- src

For a Python app:

/appdrive/docker/apps/my-python-app
|-- docker-compose.yml
|-- Dockerfile
|-- requirements.txt
`-- app

The goal is that each app is self-contained. You should be able to open the app folder and understand how that app is started, logged, rebuilt and proxied.

The shared Docker proxy network

Public-facing apps join a shared Docker network, usually called:

proxy

Nginx Proxy Manager also joins that network. That allows it to reach app containers by container name.

Example:

Public request:
https://nicelink.company.com

Proxy forwards to:
nicelink-web:80

That means nicelink-web does not need to publish port 80 to the internet. It only needs to be reachable inside Docker.

Nginx Proxy Manager setup

Nginx Proxy Manager normally exposes:

80   public HTTP
443  public HTTPS
81   admin UI

A basic Compose file looks like this:

services:
  nginx-proxy-manager:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "81:81"
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    networks:
      - proxy

networks:
  proxy:
    external: true

Store the proxy stack somewhere predictable:

/appdrive/docker/proxy/nginx-proxy-manager

The important folders are:

/appdrive/docker/proxy/nginx-proxy-manager/data
/appdrive/docker/proxy/nginx-proxy-manager/letsencrypt

Those hold proxy configuration and certificate data. They should be included in any backup plan.

Adding a new app

The operational flow for onboarding a new app should be simple:

1. Create the app folder under /appdrive/docker/apps.
2. Add docker-compose.yml.
3. Start the container or stack.
4. Make sure it joins the proxy network.
5. Point DNS to the VM public IP.
6. Add a Proxy Host in Nginx Proxy Manager.
7. Request an SSL certificate.
8. Test HTTPS access.

In Nginx Proxy Manager, the proxy host usually looks like this:

Domain Names: app.company.com
Scheme: http
Forward Hostname/IP: container-name
Forward Port: internal-container-port

Example:

Domain Names: nicelink.company.com
Scheme: http
Forward Hostname/IP: nicelink-web
Forward Port: 80

Recommended options:

Block Common Exploits: enabled
Websockets Support: enabled only when needed
Force SSL: enabled after certificate issue
HTTP/2 Support: enabled

Daily commands operators need

Most operations are ordinary Docker Compose commands.

Start an app:

cd /appdrive/docker/apps/<app-name>
docker compose up -d

Restart an app:

docker compose restart

Rebuild an app:

docker compose up -d --build

View logs:

docker compose logs -f

Restart Nginx Proxy Manager:

cd /appdrive/docker/proxy/nginx-proxy-manager
docker compose restart

Keep the commands boring. The more predictable this platform is, the easier it is for another operator to support it later.

Security model

The platform uses layered security. Azure controls what reaches the VM, Linux controls who can log in and operate the host, and Docker keeps app processes separated.

Security and container flow showing internet traffic through Azure NSG to Docker Engine containers and Fail2ban controlling blocked IPs and SSH access
Azure network rules provide the first boundary, Docker hosts isolated app containers, and Fail2ban helps reduce repeated SSH login attempts.

Recommended exposure:

AreaRecommendation
SSHRestrict to trusted IPs and use SSH keys
NPM admin UIRestrict port 81 to operators
App trafficUse HTTPS on port 443
App containersAvoid direct public port exposure
Docker accessGive only to trusted operators

Linux groups can separate responsibilities:

infra  = infrastructure administrators
dev    = app file access
docker = Docker operations
sudo   = full server administration

Remember that membership in the docker group is powerful. A user who can control Docker can usually do much more than just restart an app.

Fail2ban is also useful for SSH hardening:

sudo systemctl status fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd

Weekly checks

The platform does not need a heavy process, but it does need regular checks.

A practical weekly routine:

docker ps
df -h
df -h /appdrive
docker system df
sudo fail2ban-client status

Check the proxy stack:

cd /appdrive/docker/proxy/nginx-proxy-manager
docker compose ps

Check important app stacks:

cd /appdrive/docker/apps/<app-name>
docker compose ps

Review certificates in Nginx Proxy Manager as well. Look for failed renewals, expired certificates and old proxy hosts that no longer need to exist.

Do not aggressively run:

docker system prune

That command can remove images, stopped containers and other resources. Use it only when you understand what will be removed.

Nginx Proxy Manager vs raw Nginx

Nginx Proxy Manager is the platform standard here, but raw Nginx is also a valid architectural option.

The reason for choosing Nginx Proxy Manager is operational speed. In a shared hosting environment, new domains, certificates and proxy hosts may need to be added regularly. A UI makes that easier for a small team.

Nginx Proxy Manager is useful when the model is:

One VM.
Multiple Docker apps.
Multiple domains or subdomains.
One reverse proxy.
Simple HTTPS management.

Why Nginx Proxy Manager works well here

ReasonBenefit
Automated SSLCertificates can be requested and renewed through the UI
Faster operationsNew proxy hosts can be added quickly
Lower barrierOperators do not need deep Nginx knowledge for standard routing
Centralised managementProxy hosts, certificates and routing live in one place
Frequent changesGood fit for prototypes, dashboards and internal tools

When raw Nginx may be better

Raw Nginx may be preferred when configuration discipline matters more than speed.

ScenarioWhy raw Nginx may fit better
GitOps / Infrastructure as CodeNginx config can be version-controlled in Git
Complex routingBetter for advanced rewrites, headers and routing rules
Strict disaster recoveryConfig can be restored exactly from source control
Minimal footprintA raw Nginx container is lighter
Controlled productionChanges can go through reviewed config files

Example cases:

- Complex path-based routing
- Advanced API gateway-style routing
- Header rewrites
- Strict configuration-as-code requirements
- Fully automated CI/CD infrastructure deployments

The trade-off is simple:

Nginx Proxy Manager gives speed, accessibility and certificate automation. Raw Nginx gives deeper control and better version-control discipline, but requires stronger Nginx knowledge.

For this platform, the business value is fast and secure onboarding of multiple small apps. That is why Nginx Proxy Manager is the preferred standard.

Limitations to be honest about

This setup is useful, but it has limits.

It is not designed to replace Kubernetes, Azure App Service, Azure Container Apps or a full high-availability platform.

Current limitations include:

- Single VM dependency
- Manual scaling
- Manual backup process unless automated later
- Docker group users are trusted operators
- No built-in multi-node failover
- No automatic container health remediation beyond Docker restart policies
- No built-in centralised monitoring or alerting yet
- Nginx Proxy Manager configuration is stored locally, not fully Git-versioned

The main risks are predictable:

RiskMitigation
Single server failureAdd backup and recovery process
Disk growthMonitor /appdrive and Docker usage
Docker access powerLimit Docker access to trusted users
Proxy misconfigurationDocument and review critical route changes
Certificate renewal failureCheck NPM certificate status regularly
Manual deployment errorIntroduce CI/CD when needed

What to improve later

Future improvements may include:

- Automated backups for /appdrive
- Backup and restore runbook
- Monitoring and alerting
- Centralised logging
- CI/CD from Azure DevOps or GitHub Actions
- Private container registry
- Standard app templates
- Environment-specific folders
- Docker image update process with approval
- Nginx Proxy Manager backup process

The backup scope should include at least:

/appdrive/docker/apps
/appdrive/docker/proxy/nginx-proxy-manager/data
/appdrive/docker/proxy/nginx-proxy-manager/letsencrypt
Application-specific data folders

Those are the locations that allow the platform and hosted apps to be restored with the least confusion.

Final operating principle

The operating principle is:

Keep the VM secure.
Keep apps containerised.
Keep routing centralised.
Keep app files organised.
Keep access controlled.

This platform works best when it stays boring.

One app folder. One Compose file. One container or stack. One proxy host. One HTTPS endpoint.

That is enough structure to support multiple apps without turning a simple hosting platform into an orchestration project.

Related insights

Related insights

Azure · 10 min read

Azure Linux VM: add and mount a data disk

A practical field guide for attaching a new Azure data disk to a Linux VM, formatting it, mounting it at /web and making the mount survive reboot.

Read next

Azure · 8 min read

When containers make sense for an SME web system on Azure

A practical guide to Azure container hosting choices, trade-offs and when simpler hosting is the better route.

Read next

Next step

Need help applying this?

Tell LDS about the website, app, portal or integration you are planning.