Administração Linux

Configurando uma stack LAMP: Apache, MariaDB, PHP-FPM, virtual hosts e TLS

Intermediário9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Um servidor LAMP são quatro serviços independentes que precisam concordar sobre sockets, usuários e permissões de arquivos. Este guia monta um servidor com intenção, em vez de copiar um comando pronto: Apache com o MPM event e virtual hosts por nome, MariaDB com uma conta de aplicação de privilégio mínimo, PHP-FPM no próprio pool, uma propriedade sensata do document root e TLS do Let's Encrypt. Cada etapa termina com uma forma de verificá-la.

O que você vai aprender

  • Instalar e verificar Apache, MariaDB e PHP-FPM em um servidor Debian ou da família RHEL
  • Configurar virtual hosts por nome com document roots e arquivos de log separados
  • Criar um banco de dados e uma conta de aplicação apenas com os privilégios de que ela precisa
  • Conectar o Apache ao PHP-FPM via socket e ajustar um pool à memória disponível
  • Obter e renovar um certificado Let's Encrypt e aplicar um checklist básico de hardening

O LAMP é há vinte e cinco anos a forma padrão de servir um site dinâmico, e o significado da sigla mudou discretamente nesse meio-tempo. O L de Linux continua o mesmo. O A ainda é o Apache, mas o modelo de processos recomendado é outro. O M hoje costuma ser o MariaDB. E o P nem roda mais dentro do servidor web: o PHP é um serviço separado com o qual o Apache conversa por um socket.

Essa última mudança é a que vale a pena entender, porque explica a maior parte da configuração a seguir. Este guia monta passo a passo um servidor funcional em um host Debian ou Ubuntu atual, aponta os equivalentes na família RHEL e verifica cada camada antes de passar para a próxima. Não há nada exótico aqui; o valor está em fazer tudo com intenção, para que, quando algo quebrar, você saiba qual dos quatro serviços investigar.

Instalando as peças

# Debian / Ubuntu
sudo apt update
sudo apt install apache2 mariadb-server php-fpm php-mysql php-mbstring php-xml php-curl php-zip

# RHEL family (Rocky, Alma, RHEL) — Apache is packaged as httpd
sudo dnf install httpd mariadb-server php-fpm php-mysqlnd php-mbstring php-xml
sudo systemctl enable --now httpd mariadb php-fpm

Os nomes dos pacotes mudam (apache2 versus httpd, php-mysql versus php-mysqlnd), e a organização da configuração também: o Debian usa /etc/apache2/ com sites-available e sites-enabled, além dos utilitários a2ensite e a2enmod, enquanto o RHEL usa um diretório plano, /etc/httpd/conf.d/. Todo o resto se aplica igualmente. Se o próprio gerenciamento de pacotes não for familiar, o guia de gerenciamento de pacotes cobre repositórios e fixação de versões.

Confira se os serviços estão no ar antes de mexer em qualquer configuração:

$ systemctl is-active apache2 mariadb php8.3-fpm
active
active
active
$ sudo ss -ltnp | grep -E ':(80|3306)'
LISTEN 0 511  0.0.0.0:80   0.0.0.0:* users:(("apache2",pid=1201,fd=4))
LISTEN 0 80  127.0.0.1:3306 0.0.0.0:* users:(("mariadb",pid=1408,fd=25))

Repare que o MariaDB escuta por padrão apenas na interface de loopback. Isso está correto para um host LAMP de servidor único e deve continuar assim: um banco de dados com endereço público é um convite, e o guia de redes explica como acessá-lo por um túnel SSH quando você precisar de um cliente no seu notebook.

Apache: modelo de processos e módulos

O Apache 2.4 escolhe um módulo de multiprocessamento (MPM) na compilação ou na configuração. O prefork usa um processo por conexão e existe principalmente por causa do mod_php; o event é um modelo com threads que tira as conexões keep-alive ociosas das threads de trabalho e aguenta muito mais concorrência com a mesma memória. Com o PHP rodando sob o FPM, event é a escolha certa.

sudo a2dismod php8.3 mpm_prefork 2>/dev/null
sudo a2enmod mpm_event proxy_fcgi setenvif rewrite headers ssl http2
sudo a2enconf php8.3-fpm
sudo systemctl restart apache2
apache2ctl -M | grep -E 'mpm|fcgi|rewrite'

O proxy_fcgi é quem encaminha as requisições PHP para o FPM; o rewrite é exigido pelo front controller de praticamente todo framework PHP; o headers permite definir cabeçalhos de segurança; e ssl e http2 você vai querer em breve. Não habilite mais nada: cada módulo é código no caminho da requisição.

Ajustar o MPM event é questão de aritmética, não de folclore. O Apache agora é leve, já que o PHP está em outro lugar, então os limites servem principalmente para impedir que você enfileire mais trabalho do que a máquina consegue fazer:

# /etc/apache2/mods-available/mpm_event.conf
<IfModule mpm_event_module>
    StartServers             2
    MinSpareThreads         25
    MaxSpareThreads         75
    ThreadsPerChild         25
    MaxRequestWorkers      150
    MaxConnectionsPerChild   0
</IfModule>

Virtual hosts

Um virtual host por nome permite que um único endereço sirva vários sites; o Apache escolhe o bloco cujo ServerName ou ServerAlias corresponde ao cabeçalho Host. Dê a cada site seu próprio arquivo, seu próprio document root e seus próprios arquivos de log.

# /etc/apache2/sites-available/example.com.conf
<VirtualHost *:80>
    ServerName  example.com
    ServerAlias www.example.com
    DocumentRoot /var/www/example.com/public

    <Directory /var/www/example.com/public>
        Options -Indexes +FollowSymLinks
        AllowOverride None
        Require all granted
    </Directory>

    # Never serve the application's internals, even if a path escapes the root
    <DirectoryMatch "/(\.git|vendor|storage)/">
        Require all denied
    </DirectoryMatch>

    ErrorLog  ${APACHE_LOG_DIR}/example.com-error.log
    CustomLog ${APACHE_LOG_DIR}/example.com-access.log combined
</VirtualHost>
sudo a2ensite example.com
sudo apache2ctl configtest       # or: httpd -t
sudo systemctl reload apache2

Quatro detalhes desse bloco merecem explicação. O DocumentRoot aponta para um subdiretório public/, e não para a raiz do projeto, de modo que arquivos de configuração e dependências ficam fisicamente fora da árvore servida. Options -Indexes impede que o Apache gere uma listagem do diretório quando não há arquivo de índice, uma fonte rotineira de vazamento acidental. AllowOverride None desativa o .htaccess: o Apache deixa de procurar esses arquivos a cada requisição e sua configuração fica em um só lugar; habilite-o apenas para aplicações que realmente precisem. E Require all granted é a sintaxe de autorização do 2.4 que substituiu Order allow,deny; tutoriais antigos que mostram a forma do 2.2 produzem uma configuração que não carrega.

Rode sempre o configtest antes de recarregar. Um erro de sintaxe no reload mantém a configuração antiga servindo; um erro de sintaxe descoberto só no restart deixa o site fora do ar.

MariaDB: proteja primeiro, depois aplique o privilégio mínimo

A primeira coisa a executar depois da instalação é o script de hardening, que remove as contas anônimas e o banco de testes e oferece a opção de desabilitar o login remoto do root:

sudo mariadb-secure-installation     # mysql_secure_installation on MySQL

Nas versões atuais do MariaDB, a conta root se autentica pelo plugin unix_socket: sudo mariadb funciona sem senha, e o login remoto do root é impossível por construção. Deixe assim. As aplicações recebem a própria conta, restrita ao próprio banco e ao host de onde se conectam:

CREATE DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER 'shop_app'@'localhost' IDENTIFIED BY 'a-long-random-passphrase';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost';

-- A separate account for migrations, used only by the deployment process
CREATE USER 'shop_ddl'@'localhost' IDENTIFIED BY 'another-long-passphrase';
GRANT ALL PRIVILEGES ON shop.* TO 'shop_ddl'@'localhost';

FLUSH PRIVILEGES;
SHOW GRANTS FOR 'shop_app'@'localhost';

utf8mb4 é a única escolha correta de conjunto de caracteres; o antigo utf8 do MySQL armazena no máximo três bytes por caractere e não consegue representar emojis nem alguns caracteres CJK. Separar a conta de execução da conta de migração de schema faz com que uma falha de SQL injection na aplicação não consiga apagar uma tabela, um controle barato e eficaz. O lado de schema e consultas é tratado em boas práticas de SQL.

Duas tarefas operacionais cabem aqui, e não mais adiante. Faça um backup e prove que consegue restaurá-lo:

sudo mariadb-dump --single-transaction --routines --events shop | zstd -o /srv/backups/shop-$(date +%F).sql.zst
zstd -dc /srv/backups/shop-2026-09-07.sql.zst | sudo mariadb shop_restore_test

E mantenha as credenciais fora do histórico do shell e da listagem de processos, colocando-as em um arquivo de opções com modo 600 em vez de passá-las na linha de comando:

# /root/.my.cnf   (chmod 600)
[client]
user = root
password = only-if-you-are-not-using-socket-auth

PHP-FPM

O FPM roda pools: grupos independentes de processos worker, cada um com seu usuário, seu socket e seus limites. Dê a cada site o próprio pool, para que uma aplicação descontrolada não consiga sufocar as outras e para que a propriedade dos arquivos continue separada.

; /etc/php/8.3/fpm/pool.d/example.com.conf
[example.com]
user = www-example
group = www-example
listen = /run/php/php8.3-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

php_admin_value[error_log] = /var/log/php/example.com-error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 16M
php_admin_value[post_max_size] = 16M
php_admin_value[open_basedir] = /var/www/example.com:/tmp

O número que importa é pm.max_children. Multiplique-o pela memória média que uma requisição usa (meça, não chute), e o resultado precisa caber com folga na RAM, deixando espaço para o MariaDB e para o page cache. Vinte workers a 80 MB dão 1,6 GB, o que já é demais para uma máquina de 2 GB. pm.max_requests recicla um worker após um certo número de requisições, disfarçando vazamentos lentos em código de terceiros.

open_basedir confina o interpretador aos caminhos listados, de modo que uma falha de inclusão de arquivo não consiga ler /etc/passwd. Ao contrário de um php_value comum, o que é definido com php_admin_value não pode ser sobrescrito pela aplicação em tempo de execução.

Aponte o virtual host para o socket:

    <FilesMatch \.php$>
        SetHandler "proxy:unix:/run/php/php8.3-fpm-example.sock|fcgi://localhost/"
    </FilesMatch>
    <FilesMatch "\.(env|ini|log|sh|sql|bak)$">
        Require all denied
    </FilesMatch>
sudo systemctl reload php8.3-fpm apache2

Em produção, ative o cache de opcodes e desative as configurações que vazam informação. Em /etc/php/8.3/fpm/php.ini:

display_errors = Off
expose_php = Off
opcache.enable = 1
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax

opcache.validate_timestamps = 0 impede que o PHP verifique a data de modificação dos arquivos a cada requisição, um ganho mensurável, mas significa que você precisa recarregar o FPM a cada deploy. Faça disso parte do seu script de deploy, e não algo de que você precise se lembrar.

Permissões do document root

É aqui que a maioria das instalações LAMP dá errado silenciosamente, em geral por causa de um chmod -R 777. O princípio é simples: o servidor web deve conseguir ler a aplicação e escrever apenas nos poucos diretórios que realmente precisam disso.

sudo useradd -r -M -d /var/www/example.com -s /usr/sbin/nologin www-example
sudo chown -R deploy:www-example /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 750 {} +
sudo find /var/www/example.com -type f -exec chmod 640 {} +

# Only these need to be writable by the PHP worker
sudo chown -R www-example:www-example /var/www/example.com/storage /var/www/example.com/var/cache
sudo chmod -R 770 /var/www/example.com/storage

Os arquivos pertencem à conta de deploy e podem ser lidos pelo grupo do pool; o usuário do pool é dono apenas dos diretórios graváveis. Nada é legível por todos, e arquivos enviados não podem ser executados, porque o interpretador só processa caminhos que casam com o handler do FilesMatch; é também por isso que um diretório de uploads deve ser servido a partir de um caminho onde o tratamento de .php esteja totalmente desligado. O raciocínio por trás desses modos, e a alternativa com ACLs quando a propriedade fica complicada, está em usuários, grupos e permissões.

Em um host da família RHEL, o SELinux adiciona uma segunda camada. Os arquivos precisam ter o rótulo correto, e o daemon precisa de um booleano antes de poder, por exemplo, abrir uma conexão de rede:

sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/storage(/.*)?"
sudo restorecon -Rv /var/www/example.com
sudo setsebool -P httpd_can_network_connect_db on

Desligar o SELinux não é solução; ausearch -m avc -ts recent diz exatamente o que foi negado.

TLS com Let's Encrypt

O Certbot obtém um certificado, edita o virtual host e instala um timer de renovação.

sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d example.com -d www.example.com --redirect --agree-tos -m [email protected]
sudo certbot renew --dry-run
systemctl list-timers 'certbot*'

--redirect adiciona o redirecionamento permanente da porta 80 para a 443. O challenge HTTP-01 precisa da porta 80 aberta para a internet, então deixe-a acessível em vez de bloqueá-la no firewall depois da primeira emissão. Em seguida, defina explicitamente a política de protocolos no virtual host TLS:

    SSLProtocol -all +TLSv1.2 +TLSv1.3
    SSLHonorCipherOrder off
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Protocols h2 http/1.1

Ative o HSTS só quando tiver certeza de que todos os subdomínios vão continuar funcionando em HTTPS: os navegadores guardam o cabeçalho em cache durante toda a sua validade, e não há como desfazer isso rapidamente. Comece com um max-age curto, confirme que nada quebrou e depois aumente.

Verificando a stack inteira

$ curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://example.com/
301 https://example.com/
$ curl -sSI https://example.com/ | head -6
HTTP/2 200
server: Apache
strict-transport-security: max-age=31536000; includeSubDomains
$ printf '<?php echo "db: ", (new PDO("mysql:host=localhost;dbname=shop","shop_app","...")) ? "ok" : "fail";' \
    | sudo -u www-example php
db: ok
$ sudo tail -n 20 /var/log/apache2/example.com-error.log

Confirme quatro coisas de forma independente: o redirecionamento funciona, o TLS termina com os cabeçalhos que você definiu, o PHP alcança o banco de dados como usuário do pool e o log de erros está silencioso. Nunca deixe uma página phpinfo() esquecida: ela expõe seus caminhos, suas extensões e, muitas vezes, variáveis de ambiente.

Checklist de hardening

  • Mantenha o host atualizado, de preferência com as atualizações automáticas de segurança ativadas.
  • Bloqueie no firewall tudo exceto 80, 443 e SSH; mantenha o MariaDB no loopback.
  • Rode cada site com o usuário do seu próprio pool do FPM, com open_basedir definido.
  • display_errors = Off, expose_php = Off, ServerTokens Prod e ServerSignature Off.
  • Negue no virtual host o acesso a arquivos ocultos, .env, backups e diretórios de controle de versão.
  • Desative o .htaccess, a menos que uma aplicação precise dele.
  • Defina LimitRequestBody e use mod_reqtimeout e mod_evasive ou um reverse proxy para um rate limiting básico.
  • Faça a rotação e o monitoramento dos logs de cada site; envie-os para um lugar onde sobrevivam à máquina.
  • Faça backup do banco de dados e do diretório de uploads, e restaure ambos periodicamente em um ambiente descartável.

Erros comuns

  • chmod -R 777 no document root. Qualquer conta local passa a poder reescrever sua aplicação. Corrija a propriedade dos arquivos em vez disso.
  • Apontar o DocumentRoot para a raiz do projeto. .env, composer.json e .git passam a poder ser baixados.
  • Deixar AllowOverride All ligado por hábito. Isso custa verificações no sistema de arquivos a cada requisição e espalha a configuração.
  • Usar a sintaxe de autorização do Apache 2.2. Order allow,deny não se aplica mais; use Require.
  • Reiniciar em vez de recarregar depois de mudar a configuração. configtest seguido de reload mantém o site no ar se você tiver cometido um erro de digitação.
  • Colocar pm.max_children alto porque um tutorial fez isso. Multiplique primeiro pela memória por requisição; superdimensionar transforma um pico de tráfego em swap.
  • Conceder ALL PRIVILEGES à conta da aplicação. Separe as credenciais de execução das de migração.
  • Desligar o SELinux para fazer um 403 sumir. Rotule os arquivos; o ausearch diz quais.

Principais conclusões

  • Um LAMP moderno roda o PHP como um serviço FPM separado, o que libera o Apache para usar o MPM event com threads e permite que cada site rode com seu próprio usuário.
  • Dê a cada site o próprio arquivo de virtual host, um document root sob public/, seus arquivos de log e seu pool do FPM.
  • Sirva a partir de um diretório que o usuário web consiga ler mas não gravar, com uma lista curta de caminhos explicitamente graváveis; nunca 777.
  • Proteja o MariaDB já na instalação, mantenha-o no loopback, use utf8mb4 e separe a conta de execução da conta de migração.
  • Dimensione pm.max_children a partir da memória por requisição medida, e ative o OPcache com a validação de timestamps desligada e um reload do FPM no seu deploy.
  • O Let's Encrypt via certbot cuida da emissão e da renovação; adicione o HSTS e os demais cabeçalhos de segurança de forma deliberada, começando com uma validade curta.
  • Verifique cada camada com configtest, curl -I, uma conexão ao banco como usuário do pool e um log de erros silencioso antes de dar o servidor por concluído.

Fontes e leituras complementares

Perguntas frequentes

Devo usar MySQL ou MariaDB?
Para um servidor LAMP novo, qualquer um dos dois funciona, e ambos estão nos repositórios padrão. O MariaDB é o padrão na maioria das distribuições Linux, é totalmente open source e continua compatível em nível de protocolo com as aplicações PHP típicas. Escolha o MySQL quando uma aplicação exigir explicitamente um recurso do Oracle MySQL ou uma versão específica. Seja qual for a escolha, mantenha a biblioteca cliente e o servidor em versões compatíveis e evite misturar pacotes dos dois projetos no mesmo host.
Qual é a diferença entre mod_php e PHP-FPM?
O mod_php embute o interpretador em cada processo do Apache, o que obriga o uso do MPM prefork e de um processo pesado por conexão. O PHP-FPM roda o PHP como um pool separado de workers com o qual o Apache conversa por um socket; assim, o Apache pode usar o MPM event com threads, arquivos estáticos quase não custam nada e cada site pode rodar com seu próprio usuário e seus próprios limites. O FPM é a recomendação atual para qualquer coisa além de um site de brinquedo.
Por que recebo 403 Forbidden em um diretório que claramente existe?
Três causas respondem por quase todos os casos. O usuário do Apache não consegue atravessar algum diretório do caminho, então confira cada um com namei -l. Não existe um Require all granted para esse diretório na configuração. Ou o SELinux está em modo enforcing e os arquivos não têm o rótulo httpd_sys_content_t. Leia o log de erros primeiro: o Apache diz qual das três ele encontrou, e ficar adivinhando desperdiça muito mais tempo.
Como as renovações do Let's Encrypt acontecem de fato?
O pacote certbot instala um timer do systemd que roda duas vezes por dia, verifica todos os certificados e renova os que tiverem menos de trinta dias de validade. Nada é renovado antes disso, então é normal o timer rodar com frequência. Verifique o caminho completo com certbot renew --dry-run, que executa um challenge real contra o ambiente de staging, e garanta que a porta 80 continue acessível, porque o challenge HTTP-01 precisa dela.