// DevOps

How deployment and admin panels are set up: phpMyAdmin and RabbitMQ behind nginx

Published on 2026-09-22

How deployment and admin panels are organized: phpMyAdmin and RabbitMQ behind nginx

The choice between phpMyAdmin and Adminer for managing the database via browser is discussed in the article “phpMyAdmin, Adminer or pgAdmin”.

In this note we’ll look at a practical deployment scheme using GitHub Actions and Docker Compose, and how to expose admin panels phpMyAdmin and RabbitMQ through a single nginx gateway.

The main idea is simple: only nginx is exposed externally, and all internal services — API, frontend, database, phpMyAdmin, RabbitMQ and workers — stay inside the internal Docker network. Deployment is fully automated: after a push to main CI builds the environment, syncs code to the server, rebuilds containers, runs migrations and scales workers.

Public domains, server names and paths in the article are anonymized. Examples use example.com, api.example.com, pma.example.com, rmq.example.com and a hypothetical path /srv/project.


Overall diagram

GitHub (push → main)
        │
        ▼
  GitHub Actions
  ┌─────────────────────────────┐
  │ 1. Build .env               │
  │ 2. Generate .htpasswd       │
  │ 3. rsync → server           │
  │ 4. docker compose build     │
  │ 5. docker compose up -d     │
  │ 6. Migrations               │
  │ 7. nginx -s reload          │
  │ 8. Scale workers            │
  └─────────────────────────────┘
        │
        ▼
    Application server
  ┌──────────────────────────────────────────┐
  │  gateway (nginx)  ← the only entry point │
  │  ├── example.com       → frontend        │
  │  ├── api.example.com   → api-php-fpm     │
  │  ├── pma.example.com   → phpmyadmin      │
  │  └── rmq.example.com   → rabbitmq:15672  │
  └──────────────────────────────────────────┘

Here gateway is an nginx container that accepts HTTP/HTTPS traffic and proxies requests into the internal Docker network. Only ports 80 and 443 are open externally on the server. phpMyAdmin and RabbitMQ do not publish their ports directly to the outside.


Part 1. What happens on push to main

Step 1. Building .env

The .env file is not stored in Git. It is created anew on each deploy inside CI. This reduces the risk of accidentally publishing secrets to the repository and simplifies environment management.

All secrets live in GitHub Actions under:

Repository → Settings → Secrets and variables → Actions

The separation logic is:

  • vars.* — non-secret settings: service names, domains, modes, public parameters;
  • secrets.* — passwords, tokens, private keys and any values that must not be shown in logs or stored in code.

Example CI step:

yaml
# deploy.yml
- name: Create .env
  env:
    MYSQL_USER: ${{ vars.MYSQL_USER }}
    MYSQL_DATABASE: ${{ vars.MYSQL_DATABASE }}
    MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
    RABBITMQ_USER: ${{ vars.RABBITMQ_USER }}
    RABBITMQ_PASSWORD: ${{ secrets.RABBITMQ_PASSWORD }}
    RABBITMQ_VHOST: ${{ vars.RABBITMQ_VHOST }}
  run: |
    echo "DB_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE}" >> .env
    echo "MESSENGER_TRANSPORT_DSN=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672/${RABBITMQ_VHOST}" >> .env

Important note: GitHub masks values from secrets, but you should not intentionally print them to the log. Even if secrets are hidden, it’s better to design the pipeline so that passwords and tokens never end up in stdout.

Also watch out for special characters in passwords. If a password contains characters meaningful for URLs, it needs to be properly URL-encoded, otherwise the DSN may become invalid. This is particularly relevant for connection strings like:

mysql://user:password@db:3306/database
amqp://user:password@rabbitmq:5672/vhost

Step 2. Generating .htpasswd for the panels

At the same step basic-auth files for nginx are created. They are needed to protect admin panels with an additional browser login and password.

For phpMyAdmin a separate login is used:

bash
# For phpMyAdmin — separate login
mkdir -p gateway/docker/production/nginx/auth

echo "${PMA_AUTH_USER}:$(openssl passwd -apr1 "${PMA_AUTH_PASSWORD}")" \
  > gateway/docker/production/nginx/auth/.htpasswd

For RabbitMQ the same credentials as RabbitMQ itself are used:

bash
# For RabbitMQ — use the same credentials as RMQ itself
mkdir -p gateway/docker/production/nginx/auth

echo "${RABBITMQ_USER}:$(openssl passwd -apr1 "${RABBITMQ_PASSWORD}")" \
  > gateway/docker/production/nginx/auth/.htpasswd_rmq

The command:

bash
openssl passwd -apr1 "password"

creates a hash in Apache MD5 format, also known as apr1. Nginx can read such hashes in files referenced by the auth_basic_user_file directive.

Files are placed into the directory:

gateway/docker/production/nginx/auth/

They are then synchronized to the server together with the code.

These files should not be stored in Git. The auth files directory or the files themselves should be added to .gitignore:

gitignore
gateway/docker/production/nginx/auth/.htpasswd
gateway/docker/production/nginx/auth/.htpasswd_rmq

Otherwise you might accidentally commit password hashes. A hash is not a plaintext password, but it should not be published either: it can be brute-forced offline.


Step 3. rsync to the server

After preparing .env and .htpasswd the code is synchronized to the server.

Example command:

bash
rsync -avz --delete \
  --exclude='.git' \
  --exclude='api/vendor' \
  --exclude='frontend/node_modules' \
  ./ deploy@app-server:/srv/project/

The --delete flag means files deleted in the CI working copy will also be deleted on the server. This is useful for clean synchronization so the server doesn’t become a repository of old files.

But --delete has an important consequence: anything that must live only on the server should not be placed in the directory that rsync fully overwrites. For example, runtime data, uploads, database volume data and files created by the application are better stored outside the sync directory or mounted via Docker volumes.

In this variant the following are excluded from sync:

bash
--exclude='.git'
--exclude='api/vendor'
--exclude='frontend/node_modules'

This makes sense: .git is not needed on the production server, PHP and Node.js dependencies are usually installed or built inside images rather than copied as developer local directories.


Steps 4–8. Deploy on the server

After synchronization CI logs into the server and runs Docker Compose commands.

First new images are built:

bash
# Build new images. Old containers are still running at this point.
docker compose -f docker-compose-production.yml build

This step does not switch the running application by itself. It only builds new image versions. Old containers continue to run until up is executed.

Next containers are updated:

bash
# Switch containers
docker compose -f docker-compose-production.yml up -d --remove-orphans

up -d runs containers in the background. If a service configuration or image changed, Compose will recreate the corresponding containers. The --remove-orphans flag removes containers from services that used to be in the compose file but are now removed from the configuration.

Downtime here is typically minimal, but this should not be called a full zero-downtime deployment. If the API container is recreated as a single instance, a short interruption is possible. True zero-downtime requires additional mechanisms: multiple replicas, healthchecks, proper traffic switching and a careful update strategy.

After starting containers migrations are run:

bash
# Wait for MySQL and run migrations
docker compose -f docker-compose-production.yml run --rm api-php-cli \
  php bin/console doctrine:migrations:migrate -n

A separate CLI container for the application is used here. This is the right approach: migrations run in the same environment as the application, with the same environment variables and dependencies.

Before reloading nginx it’s better to check the configuration:

bash
# Check nginx configuration
docker compose -f docker-compose-production.yml exec gateway nginx -t

# Reload nginx configuration without restarting the whole container
docker compose -f docker-compose-production.yml exec gateway nginx -s reload

nginx -s reload reloads the configuration without stopping the whole container. But if the configuration is invalid, reload can cause problems. So nginx -t before reload is a simple and useful safety check.

Finally workers are scaled:

bash
# Scale workers
docker compose -f docker-compose-production.yml up -d \
  --scale api-workers=5 \
  --scale log-workers=1 \
  --no-recreate

The --scale flag sets the number of containers for a given service. For example, here five instances of api-workers and one instance of log-workers are started.

The --no-recreate flag tells Compose not to recreate existing containers if not needed. For workers this is convenient: you can change the number of replicas without unnecessarily recreating already running ones.


Part 2. How phpMyAdmin and RabbitMQ are connected

Network isolation

All containers are in an internal Docker network, let’s call it backend.

Only ports 80 and 443 are open from outside, and both are listened to by gateway — the nginx container.

Internet → 443 → gateway (nginx) → internal Docker network
                                     ├── frontend
                                     ├── api-php-fpm
                                     ├── phpmyadmin:80
                                     └── rabbitmq:15672

phpMyAdmin and RabbitMQ do not have published external ports. They are reachable only via gateway.

This is an important security point. Even if someone knows the internal container name or the standard RabbitMQ port, they cannot reach it directly from the Internet.


nginx config for phpMyAdmin

Example pma.conf file:

nginx
server {
    listen 443 ssl;
    server_name pma.example.com;

    # Basic auth — prompts for login/password before any request
    auth_basic "Admin Area";
    auth_basic_user_file /etc/nginx/auth/.htpasswd;

    location / {
        proxy_pass http://phpmyadmin;
    }
}

How it works:

  1. The browser goes to https://pma.example.com.
  2. Nginx shows the browser dialog “enter login/password”.
  3. Nginx checks the entered credentials against /etc/nginx/auth/.htpasswd.
  4. If credentials don’t match — nginx returns 401 Unauthorized.
  5. If credentials match — nginx proxies the request to the phpmyadmin container on port 80.

The line:

nginx
proxy_pass http://phpmyadmin;

works thanks to Docker DNS. The name phpmyadmin must match the service name or a network alias in Docker Compose, and the nginx container must be in the same Docker network.

If nginx is not in the same network, the container name will not resolve and nginx will give an error like:

host not found in upstream "phpmyadmin"

nginx config for RabbitMQ

Example rmq.conf:

nginx
server {
    listen 443 ssl;
    server_name rmq.example.com;

    auth_basic "Admin Area";
    auth_basic_user_file /etc/nginx/auth/.htpasswd_rmq;

    location / {
        proxy_pass http://rabbitmq:15672;
    }
}

The logic here is the same as for phpMyAdmin but the upstream is different:

nginx
proxy_pass http://rabbitmq:15672;

Port 15672 is the HTTP interface for RabbitMQ Management. It is used for the web UI and the HTTP management API.

In the described scheme the management variant of the RabbitMQ image is used:

rabbitmq:3.13.2-management

This image already has the management plugin enabled, so the RabbitMQ web UI is available inside the Docker network on port 15672.


Why RabbitMQ ends up with two-layer auth

RabbitMQ Management has its own authorization: the user enters the RabbitMQ login and password inside the RabbitMQ Management UI.

But before that there is nginx basic auth.

So there are two layers:

Browser
   │
   ▼
nginx basic auth
   │
   ▼
RabbitMQ Management auth
   │
   ▼
RabbitMQ Management UI

In practice the user goes through two logins:

  1. First the browser basic-auth dialog from nginx.
  2. Then the RabbitMQ Management login form.

Why this is useful:

  • The RabbitMQ Management UI is not exposed to the Internet without prior nginx authorization.
  • Scanners and casual visitors won’t see the RabbitMQ form directly.
  • Even if the RabbitMQ password is known, an attacker must get through the external basic auth as well.

Important: nginx basic auth does not replace RabbitMQ authorization. It is an additional layer in front of the management UI.


Part 3. SSL certificates

Let’s Encrypt certificates cover all public names of the project:

example.com
www.example.com
api.example.com
rmq.example.com
pma.example.com

Next to the main containers certbot runs. It checks certificate expiry on a schedule and renews certificates automatically.

Nginx reads certificates from a shared volume, for example:

/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem

or from another path inside the container if the volume is mounted differently.

Overall flow:

certbot → renews certificates → shared volume → nginx reads certificates

After successful certificate renewal nginx must reload to pick up the new certificates. In a containerized scheme this is usually handled by one of:

  • a certbot deploy-hook that calls nginx reload;
  • a separate maintenance script;
  • a reload command in CI/CD;
  • a periodic safe nginx reload after checking the config.

A minimal check for automatic renewal:

bash
certbot renew --dry-run

If certbot runs in a container, the command will be run via Docker Compose, for example:

bash
docker compose -f docker-compose-production.yml run --rm certbot renew --dry-run

After updating a certificate it’s useful to check which certificate nginx is actually serving:

bash
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -issuer -subject

This helps catch a situation where certbot updated the certificate, but nginx continues to serve the old certificate from memory.


Part 4. Emergency manual control

Automatic deploy covers the common scenario, but you always need a clear emergency path: how to view logs, restart services, run migrations or do a full redeploy.

Full redeploy

If something is broken you can run a separate workflow manually:

GitHub → Actions → Full Redeploy → Run workflow

Such a workflow might be called redeploy.yml.

Its job is not just to update changed containers but to fully rebuild the environment:

bash
# Stop containers
docker compose -f docker-compose-production.yml down

# Build images from scratch
docker compose -f docker-compose-production.yml build --no-cache

# Start the environment again
docker compose -f docker-compose-production.yml up -d --remove-orphans

This mode is useful if you suspect a corrupted Docker image layer, conflicts with old containers or an incorrect state after a failed deploy.

But it’s a heavier operation than a normal deploy: it can cause more downtime, so use it as an emergency tool.


View gateway logs

To view nginx-gateway logs:

bash
docker compose -f docker-compose-production.yml logs -f gateway

This command is useful if:

  • the frontend doesn’t open;
  • the API returns errors through nginx;
  • phpMyAdmin doesn’t work;
  • RabbitMQ Management UI doesn’t open;
  • there’s suspicion of an upstream error;
  • nginx cannot find a container by name;
  • the new config was not applied.

Common errors visible in the logs:

host not found in upstream
connect() failed
upstream timed out
SSL_do_handshake() failed
no user/password was provided for basic authentication
user "..." was not found in "/etc/nginx/auth/.htpasswd"

View RabbitMQ logs

For RabbitMQ:

bash
docker compose -f docker-compose-production.yml logs -f rabbitmq

This helps check:

  • whether RabbitMQ started;
  • whether applications connected to the broker;
  • whether the required vhost was created;
  • authentication errors;
  • whether queues are processing;
  • disk or memory issues.

Run a SQL query via the application

For a quick check of DB connectivity you can run a SQL query via the Symfony CLI container:

bash
docker compose -f docker-compose-production.yml run --rm api-php-cli \
  php bin/console doctrine:query:sql "SELECT 1"

If this command returns a result, it means:

  • the CLI container starts;
  • environment variables are read;
  • the DSN to the database is correct;
  • MySQL is reachable from the Docker network;
  • Doctrine can execute queries.

For diagnostics this is often more convenient than connecting directly to the MySQL container, because it checks the exact path the application uses to reach the DB.


What to check before publishing this scheme to production

Below is not a new architecture, but a checklist for the scheme described above. It helps avoid small issues that usually break deployments at the worst moment.

1. .env must not end up in Git

Check .gitignore:

gitignore
.env
.env.*

If templates are needed, store only an example:

.env.example

It should contain variable names without real secrets.


2. Auth files must not end up in Git

Check:

gitignore
gateway/docker/production/nginx/auth/.htpasswd
gateway/docker/production/nginx/auth/.htpasswd_rmq

You can exclude the whole directory:

gitignore
gateway/docker/production/nginx/auth/

But then ensure CI creates the directory before writing files:

bash
mkdir -p gateway/docker/production/nginx/auth

3. Nginx must be in the same Docker network as phpMyAdmin and RabbitMQ

If nginx config contains:

nginx
proxy_pass http://phpmyadmin;
proxy_pass http://rabbitmq:15672;

then the gateway container must see phpmyadmin and rabbitmq via Docker DNS.

You can check it like this:

bash
docker compose -f docker-compose-production.yml exec gateway getent hosts phpmyadmin
docker compose -f docker-compose-production.yml exec gateway getent hosts rabbitmq

If names don’t resolve, the issue is usually Docker Compose networks: services are in different networks or nginx is not connected to the required network.


4. Run nginx -t before reload

Safe order:

bash
docker compose -f docker-compose-production.yml exec gateway nginx -t
docker compose -f docker-compose-production.yml exec gateway nginx -s reload

If nginx -t fails, do not reload. Fix the config first.


5. Test certbot with dry-run

Command:

bash
certbot renew --dry-run

or containerized variant:

bash
docker compose -f docker-compose-production.yml run --rm certbot renew --dry-run

should succeed after initial setup and after changes to nginx, DNS, firewall or reverse proxy.


6. Prefer not to publish RabbitMQ Management directly

In this scheme the management UI is available only via nginx and basic auth. This is better than exposing port 15672 to the outside via Docker ports.

So avoid this production pattern:

yaml
ports:
  - "15672:15672"

If the port is published externally, RabbitMQ Management UI will be accessible directly, bypassing nginx basic auth.


Conclusion

The scheme is built around a simple idea: all external traffic goes through one nginx gateway, and internal services remain closed in the Docker network.

On push to main GitHub Actions:

  1. Creates .env from vars and secrets.
  2. Generates .htpasswd for phpMyAdmin and RabbitMQ.
  3. Syncs code to the server via rsync.
  4. Builds Docker images.
  5. Updates containers via Docker Compose.
  6. Runs migrations.
  7. Reloads nginx.
  8. Scales workers.

phpMyAdmin and RabbitMQ are not directly exposed to the Internet. Access goes only through nginx:

pma.example.com → nginx basic auth → phpMyAdmin
rmq.example.com → nginx basic auth → RabbitMQ Management auth → RabbitMQ UI

This is a reasonable and clear production scheme: secrets are not stored in Git, admin panels are protected by an extra auth layer, SSL is renewed automatically, and emergency control remains available via commands and a manual workflow.

The main things not to forget are technical checks: .gitignore for secrets, nginx -t before reload, certbot renew --dry-run, not publishing 15672 directly, and correct Docker networking between gateway, phpmyadmin and rabbitmq.

// Reviews

Related reviews

I needed to get n8n, Redis, and the database working. I had hired another contractor before and everything kept breaking. I hired Mikhail, and the next day everything was working quickly, like clockwork!

There was a task to get n8n, redis and the database working. I had previously ordered from another contractor, it kept breaking all the time. Ordered from Mikhail, the next day everything started working fast, like …

christ_media

n8n installation on your VPS server. Configuration of n8n, Docker, AI, Telegram

2025-09-24 · ★ 5/5

Experienced buyer

Quick solution — I highly recommend Mikhail as a contractor! I tried to build a similar configuration myself and even followed AI advice, which ended up costing a lot of time and money (due to server downtime). So my advice: hire professionals — it's cheaper =) Thanks to Mikhail for his professionalism.

Quick fix for the problem, I recommend Mikhail as a contractor to everyone! I tried to assemble a similar configuration myself and following advice from neural networks, which resulted in a lot of wasted effort and …

ladohinpy

n8n installation on your VPS server. Configuration of n8n, Docker, AI, Telegram.

2025-08-25 · ★ 5/5

// Contact

Need help?

Get in touch with me and I'll help solve the problem

I reply within one business day (03:00-13:00 GMT)

Или оставьте заявку здесь:

Confirm that you are not a bot.

Write and get a quick reply