// 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 → ActionsThe 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:
# 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}" >> .envImportant 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/vhostStep 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:
# 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/.htpasswdFor RabbitMQ the same credentials as RabbitMQ itself are used:
# 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_rmqThe command:
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:
gateway/docker/production/nginx/auth/.htpasswd
gateway/docker/production/nginx/auth/.htpasswd_rmqOtherwise 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:
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:
--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:
# Build new images. Old containers are still running at this point.
docker compose -f docker-compose-production.yml buildThis 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:
# Switch containers
docker compose -f docker-compose-production.yml up -d --remove-orphansup -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:
# Wait for MySQL and run migrations
docker compose -f docker-compose-production.yml run --rm api-php-cli \
php bin/console doctrine:migrations:migrate -nA 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:
# 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 reloadnginx -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:
# Scale workers
docker compose -f docker-compose-production.yml up -d \
--scale api-workers=5 \
--scale log-workers=1 \
--no-recreateThe --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:15672phpMyAdmin 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:
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:
- The browser goes to
https://pma.example.com. - Nginx shows the browser dialog “enter login/password”.
- Nginx checks the entered credentials against
/etc/nginx/auth/.htpasswd. - If credentials don’t match — nginx returns
401 Unauthorized. - If credentials match — nginx proxies the request to the
phpmyadmincontainer on port80.
The line:
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:
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:
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-managementThis 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 UIIn practice the user goes through two logins:
- First the browser basic-auth dialog from nginx.
- 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.comNext 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.pemor from another path inside the container if the volume is mounted differently.
Overall flow:
certbot → renews certificates → shared volume → nginx reads certificatesAfter 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:
certbot renew --dry-runIf certbot runs in a container, the command will be run via Docker Compose, for example:
docker compose -f docker-compose-production.yml run --rm certbot renew --dry-runAfter updating a certificate it’s useful to check which certificate nginx is actually serving:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -issuer -subjectThis 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 workflowSuch a workflow might be called redeploy.yml.
Its job is not just to update changed containers but to fully rebuild the environment:
# 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-orphansThis 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:
docker compose -f docker-compose-production.yml logs -f gatewayThis 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:
docker compose -f docker-compose-production.yml logs -f rabbitmqThis 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:
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:
.env
.env.*If templates are needed, store only an example:
.env.exampleIt should contain variable names without real secrets.
2. Auth files must not end up in Git
Check:
gateway/docker/production/nginx/auth/.htpasswd
gateway/docker/production/nginx/auth/.htpasswd_rmqYou can exclude the whole directory:
gateway/docker/production/nginx/auth/But then ensure CI creates the directory before writing files:
mkdir -p gateway/docker/production/nginx/auth3. Nginx must be in the same Docker network as phpMyAdmin and RabbitMQ
If nginx config contains:
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:
docker compose -f docker-compose-production.yml exec gateway getent hosts phpmyadmin
docker compose -f docker-compose-production.yml exec gateway getent hosts rabbitmqIf 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:
docker compose -f docker-compose-production.yml exec gateway nginx -t
docker compose -f docker-compose-production.yml exec gateway nginx -s reloadIf nginx -t fails, do not reload. Fix the config first.
5. Test certbot with dry-run
Command:
certbot renew --dry-runor containerized variant:
docker compose -f docker-compose-production.yml run --rm certbot renew --dry-runshould 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:
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:
- Creates
.envfromvarsandsecrets. - Generates
.htpasswdfor phpMyAdmin and RabbitMQ. - Syncs code to the server via
rsync. - Builds Docker images.
- Updates containers via Docker Compose.
- Runs migrations.
- Reloads nginx.
- 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 UIThis 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
The collaboration left an extremely positive impression, primarily because of the professionalism and the approach to resolving issues as they arose.
The experience of working together left an extremely positive impression, above all because of the professionalism and the approach to solving the issues that arose.
Jitsi Meet: a personal Zoom — setup in Docker and on a VPS
2025-11-11 · ★ 5/5
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 …
n8n installation on your VPS server. Configuration of n8n, Docker, AI, Telegram
2025-09-24 · ★ 5/5
Thank you for the fast and excellent work. Everything was done promptly and just as needed!
Thank you for the quick and good work. Everything was done promptly and as needed!
n8n installation on your VPS server. Configuration of n8n, Docker, AI, Telegram
2025-09-06 · ★ 5/5
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 …
n8n installation on your VPS server. Configuration of n8n, Docker, AI, Telegram.
2025-08-25 · ★ 5/5
Mikhail completed the setup of another VPS. He quickly and professionally bypassed certain hosting providers' restrictions.
Mikhail completed the setup of another VPS. Quickly, professionally bypassing certain limitations of hosting providers.
n8n installation on your VPS server. n8n, Docker, AI, Telegram setup
2025-08-12 · ★ 5/5
Great job, thank you! Mikhail is a true professional — I recommend him!
Excellent work, thank you! Mikhail is a professional in his field, I recommend him!
N8n installation on your VPS server. Setup of n8n, Docker, AI, Telegram
2025-07-03 · ★ 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)
Или оставьте заявку здесь:
// Related