Practical guide
Java, Python and TypeScript, what the tutorials skip.
Maven and Spring profiles, uv and virtual environments, npm and tsconfig: what each tool does, how .env and environment variables reach your program, how to clean up what they leave behind, and how to run all three with Docker.
O mapa: o que toda pilha temThe map: what every stack has
Aprender uma linguagem nova costuma travar não na linguagem, e sim nas ferramentas ao redor: qual versão está instalada, onde as dependências vão parar, por que o programa não enxerga a variável que você definiu. As três pilhas desta nota (Java com Spring Boot, Python com FastAPI e TypeScript com Node) têm o mesmo esqueleto. Se você aprender o esqueleto uma vez, cada pilha vira só uma troca de nomes.Learning a new language usually gets stuck not on the language but on the tools around it: which version is installed, where dependencies end up, why the program does not see the variable you set. The three stacks in this note (Java with Spring Boot, Python with FastAPI and TypeScript with Node) share the same skeleton. Learn the skeleton once and each stack becomes a change of names.
| PeçaPiece | Java + Spring Boot | Python + FastAPI | TypeScript + Node |
|---|---|---|---|
| RuntimeRuntime | JDK 25 (java, javac) | Python 3.12 (python3) | Node 24 (node) |
| Gerenciador de dependências e buildDependency and build manager | Maven (mvn ou ./mvnw) | uv (uv) | npm (npm) |
| Manifesto (o que o projeto pede)Manifest (what the project asks for) | pom.xml | pyproject.toml | package.json |
| Lock (versões exatas)Lock (exact versions) | não há por padrão; o starter-parent fixa as versõesnone by default; the starter-parent pins versions | uv.lock | package-lock.json |
| Onde as dependências ficamWhere dependencies live | ~/.m2/repository (globalglobal) | .venv/ (por projetoper project) | node_modules/ (por projetoper project) |
| Framework webWeb framework | Spring Boot (Tomcat embutidoembedded) | FastAPI (uvicorn) | no lab, só node:httpin the lab, just node:http |
| Arquivo de configuraçãoConfiguration file | application.properties | classe Settings (+ .env)Settings class (+ .env) | objeto validado (+ .env)validated object (+ .env) |
| Porta padrão do labLab default port | 8080 | 8000 | 3000 |
Todo o código desta nota foi construído e executado de verdade (Maven 3.9.16 e uv 0.13.6 em contêineres, Node e JDK também no host). Onde algo não foi executado, a nota diz. O projeto de laboratório é o mesmo nas três linguagens: um servidor com /health e /config, que devolve uma saudação lida da configuração e se existe uma senha, sem nunca devolver a senha.All the code in this note was built and run for real (Maven 3.9.16 and uv 0.13.6 in containers, Node and the JDK on the host too). Where something was not run, the note says so. The lab project is the same in all three languages: a server with /health and /config that returns a greeting read from the configuration and whether a password is set, never returning the password itself.
Configuração: a regra que vale nas trêsConfiguration: the rule that holds in all three
Antes de cada pilha, a ideia que mais confunde iniciantes: nenhuma linguagem lê o arquivo .env sozinha. O .env é só um arquivo de texto com CHAVE=valor. Quem o transforma em configuração é uma biblioteca ou uma opção que você liga de propósito. O que todas as linguagens leem sozinhas são as variáveis de ambiente do processo, ou seja, o que o sistema operacional entrega ao programa quando ele inicia.Before each stack, the idea that confuses beginners the most: no language reads the .env file by itself. A .env is just a text file with KEY=value. What turns it into configuration is a library or an option you switch on on purpose. What every language reads by itself are the process's environment variables, that is, what the operating system hands to the program when it starts.
A escada acima é a regra comum, e eu a medi nas três (a tabela de cada linguagem mostra os números). Em todas, uma variável de ambiente vence o .env, e o .env vence o valor padrão do código. Isso é intencional: no seu computador você usa o .env; no servidor ou no Docker, o ambiente entra por fora e substitui, sem tocar no código. É a ideia do fator III do The Twelve-Factor App: configuração que muda entre ambientes mora no ambiente, não no código.The ladder above is the common rule, and I measured it in all three (each language's table shows the numbers). In every one, an environment variable beats the .env, and the .env beats the default in the code. That is intentional: on your computer you use the .env; on a server or in Docker the environment comes in from outside and overrides it, without touching the code. It is the idea of factor III of The Twelve-Factor App: configuration that changes between environments lives in the environment, not in the code.
- Variável de ambiente só vale para o processo que a recebe.
export GREETING=oiafeta aquele terminal e o que ele iniciar depois; outro terminal, o IDE e o Docker não enxergam. É a causa número um de “defini a variável e o programa não vê”. Para testar, prefixe o comando:GREETING=oi node src/server.ts.An environment variable only applies to the process that receives it.export GREETING=oiaffects that terminal and what it starts afterwards; another terminal, the IDE and Docker do not see it. It is the number one cause of “I set the variable and the program does not see it”. To test, prefix the command:GREETING=oi node src/server.ts. - Tudo é texto.
PORT=8080chega como a string"8080". Cada pilha converte de um jeito (Spring converte pelo tipo do campo, o pydantic pela anotação, no TypeScript você usa o zod). Converta e valide uma vez, na partida: se faltar algo, o app deve nem subir.Everything is text.PORT=8080arrives as the string"8080". Each stack converts in its own way (Spring converts by the field type, pydantic by the annotation, in TypeScript you use zod). Convert and validate once, at startup: if something is missing, the app should not even start. - Nunca versione o
.envreal. Coloque.envno.gitignoree no.dockerignore, e versione um.env.examplesem segredos. Um segredo que entrou no Git continua no histórico mesmo depois de apagado.Never commit the real.env. Put.envin.gitignoreand.dockerignore, and commit a.env.examplewith no secrets. A secret that got into Git stays in the history even after it is deleted. - Não devolva segredos nas respostas nem nos logs. O endpoint
/configdo laboratório responde sódbPasswordSet: true/false, nunca o valor.Do not return secrets in responses or logs. The lab's/configendpoint only answersdbPasswordSet: true/false, never the value.
Java com Spring BootJava with Spring Boot
O que precisa estar instalado: o JDKWhat must be installed: the JDK
Para desenvolver você precisa do JDK (Java Development Kit): ele traz o compilador javac e o java que executa. Para só rodar um jar basta o JRE, a parte de execução (as imagens Docker -jre trazem só ela). O Spring Boot 4.1 exige Java 17 ou mais e funciona até o Java 27, segundo a página de requisitos; nos testes usei o 25 (LTS). Confira com java -version e javac -version; se o segundo não existir, você tem só o JRE. A variável JAVA_HOME diz ao Maven qual JDK usar quando há mais de um instalado.To develop you need the JDK (Java Development Kit): it brings the javac compiler and the java launcher. To just run a jar the JRE, the runtime part, is enough (the -jre Docker images carry only that). Spring Boot 4.1 requires Java 17 or later and works up to Java 27, per the requirements page; in the tests I used 25 (LTS). Check with java -version and javac -version; if the second does not exist, you have only the JRE. The JAVA_HOME variable tells Maven which JDK to use when more than one is installed.
Maven: o que ele fazMaven: what it does
O Maven é a ferramenta de build do Java. Ele lê o pom.xml (Project Object Model), baixa do Maven Central as bibliotecas que você declarou (e as que elas precisam), compila, roda os testes e empacota o resultado. As bibliotecas ficam num repositório local global, ~/.m2/repository, compartilhado por todos os projetos. Cada biblioteca é identificada por três coordenadas: groupId:artifactId:version.Maven is Java's build tool. It reads the pom.xml (Project Object Model), downloads from Maven Central the libraries you declared (and the ones they need), compiles, runs the tests and packages the result. Libraries live in a global local repository, ~/.m2/repository, shared by all projects. Each library is identified by three coordinates: groupId:artifactId:version.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
</parent>
<groupId>demo</groupId>
<artifactId>hello</artifactId>
<version>0.0.1</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
<parent>com ospring-boot-starter-parent: herda as versões testadas juntas de centenas de bibliotecas. Por isso os<dependency>abaixo não têm versão. A versão do Spring Boot (aqui 4.1.2) é a única que você escolhe.<parent>withspring-boot-starter-parent: inherits the versions of hundreds of libraries tested together. That is why the<dependency>entries below have no version. The Spring Boot version (here 4.1.2) is the only one you choose.- Starters (
spring-boot-starter-webmvc,-actuator): uma dependência que puxa o conjunto certo de bibliotecas para um assunto. Um starter para web traz o servidor embutido, o JSON e o framework MVC.Starters (spring-boot-starter-webmvc,-actuator): one dependency that pulls in the right set of libraries for a topic. A web starter brings the embedded server, JSON and the MVC framework. spring-boot-maven-plugin: ao empacotar, reescreve o jar para ser executável (um “fat jar” com as dependências dentro e um lançador). Sem ele você teria só o jar comum, sem as dependências dentro.spring-boot-maven-plugin: when packaging, rewrites the jar to be executable (a “fat jar” with the dependencies inside and a launcher). Without it you would get only a plain jar, without the dependencies inside.
| ComandoCommand | O que fazWhat it does |
|---|---|
mvn package | Valida, compila, testa e gera target/hello-0.0.1.jar.Validates, compiles, tests and produces target/hello-0.0.1.jar. |
mvn clean package | Apaga o target/ antes. Use quando algo “não atualiza”.Deletes target/ first. Use it when something “does not update”. |
mvn spring-boot:run | Compila e roda direto, sem montar o jar. É o “modo dev”.Compiles and runs directly, without building the jar. It is the “dev mode”. |
mvn -DskipTests package | Pula os testes (use no Dockerfile; rode os testes no CI).Skips the tests (use in the Dockerfile; run the tests in CI). |
mvn dependency:tree | Mostra quem trouxe cada biblioteca. Útil para conflitos de versão.Shows who brought in each library. Useful for version conflicts. |
./mvnw). Rodei mvn wrapper:wrapper no projeto e ele criou mvnw, mvnw.cmd e .mvn/wrapper/. Na primeira execução o ./mvnw baixa a versão exata do Maven escrita em maven-wrapper.properties (aqui, 3.9.16), então ninguém precisa instalar o Maven e todos usam a mesma versão. Versione esses arquivos. A saída de ./mvnw -v confirmou Maven 3.9.16 e Java 25.Use the Maven Wrapper (./mvnw). I ran mvn wrapper:wrapper in the project and it created mvnw, mvnw.cmd and .mvn/wrapper/. On first run ./mvnw downloads the exact Maven version written in maven-wrapper.properties (here, 3.9.16), so nobody needs to install Maven and everyone uses the same version. Commit those files. The ./mvnw -v output confirmed Maven 3.9.16 and Java 25.Spring Boot: como funcionaSpring Boot: how it works
O Spring é um contêiner de objetos. Você escreve classes e o Spring as cria e as liga umas às outras (injeção de dependência): no código abaixo, o construtor de Hello pede um Settings e um Environment, e o Spring entrega os dois, sem nenhum new. O Spring Boot acrescenta a conveniência: a autoconfiguração olha o que está no classpath e configura sozinha. Pusemos o starter web, então ele sobe um Tomcat embutido na porta 8080 e registra o JSON; pusemos o actuator, então ele expõe /actuator/health. A anotação @SpringBootApplication liga tudo isso, e o main só chama SpringApplication.run.Spring is an object container. You write classes and Spring creates them and wires them together (dependency injection): in the code below, the Hello constructor asks for a Settings and an Environment, and Spring supplies both, with no new. Spring Boot adds the convenience: auto-configuration looks at what is on the classpath and configures by itself. We added the web starter, so it starts an embedded Tomcat on port 8080 and registers JSON; we added the actuator, so it exposes /actuator/health. The @SpringBootApplication annotation switches all that on, and main just calls SpringApplication.run.
package demo;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@EnableConfigurationProperties(App.Settings.class)
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
// app.greeting <- APP_GREETING (variável de ambiente) ou app.greeting (.properties)
@ConfigurationProperties(prefix = "app")
public record Settings(String greeting, String dbPassword) {}
@RestController
static class Hello {
private final Settings settings;
private final Environment env;
Hello(Settings settings, Environment env) {
this.settings = settings;
this.env = env;
}
@GetMapping("/config")
Map<String, Object> config() {
return Map.of(
"greeting", settings.greeting(),
"profiles", env.getActiveProfiles(),
"dbPasswordSet", settings.dbPassword() != null && !settings.dbPassword().isBlank());
}
}
}
@ConfigurationProperties(prefix = "app")liga o grupo de propriedadesapp.*aorecord Settings, já convertido para o tipo certo. É o jeito recomendado de ler configuração, em vez de espalhar@Value.@ConfigurationProperties(prefix = "app")binds theapp.*group of properties to theSettingsrecord, already converted to the right type. It is the recommended way to read configuration, instead of scattering@Value.- O actuator dá observabilidade pronta. Com
management.endpoint.health.probes.enabled=trueele publica/actuator/health/livenesse/readiness, que testei:{"status":"UP"}. Só expus ohealth; expor tudo (*) revela detalhes internos.The actuator gives ready-made observability. Withmanagement.endpoint.health.probes.enabled=trueit publishes/actuator/health/livenessand/readiness, which I tested:{"status":"UP"}. I exposed onlyhealth; exposing everything (*) reveals internal details. - Documentação: a referência do Spring Boot (conceitos), o Spring Initializr (gera um projeto com as dependências que você marcar) e o apêndice de propriedades (todas as chaves possíveis).Documentation: the Spring Boot reference (concepts), the Spring Initializr (generates a project with the dependencies you tick) and the properties appendix (every possible key).
Configuração: do .env ao .properties, e os perfisConfiguration: from .env to .properties, and profiles
O Spring lê a configuração de um arquivo, o src/main/resources/application.properties (ou application.yml), que vai para dentro do jar. É um arquivo de chave=valor com nomes em minúsculas separados por ponto (app.greeting). Já o .env usa MAIÚSCULAS com sublinhado (APP_GREETING). As duas formas se encontram assim:Spring reads configuration from a file, src/main/resources/application.properties (or application.yml), which goes inside the jar. It is a key=value file with lowercase names separated by dots (app.greeting). A .env uses UPPERCASE with underscores (APP_GREETING). The two forms meet like this:
spring.application.name=hello
server.port=${PORT:8080}
# valores padrão; qualquer um pode ser trocado por variável de ambiente
app.greeting=olá do application.properties
app.db-password=
management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true
# lê o arquivo .env (se existir) como se fosse um .properties
spring.config.import=optional:file:.env[.properties]
- Variável de ambiente → propriedade, automático. O Spring converte o nome: troca
_por.e ignora maiúsculas, entãoAPP_GREETINGviraapp.greetingeAPP_DB_PASSWORDviraapp.db-password. Medi:APP_DB_PASSWORD=xeAPP_DBPASSWORD=xligaram o campodbPassword. A referência descreve a regra em “Binding From Environment Variables”.Environment variable → property, automatic. Spring converts the name: it turns_into.and ignores case, soAPP_GREETINGbecomesapp.greetingandAPP_DB_PASSWORDbecomesapp.db-password. I measured:APP_DB_PASSWORD=xandAPP_DBPASSWORD=xboth set thedbPasswordfield. The reference describes the rule under “Binding From Environment Variables”. - Placeholder:
${VARIAVEL:padrão}. Um valor do.propertiespode citar outra propriedade ou variável. É o que a linhaserver.port=${PORT:8080}faz: usaPORTse existir, senão 8080. É assim que você faz o.properties“ler” uma variável com nome livre.Placeholder:${VARIABLE:default}. A.propertiesvalue can cite another property or variable. That is what the lineserver.port=${PORT:8080}does: it usesPORTif it exists, otherwise 8080. This is how you make the.properties“read” a variable with a free name. - O
.envsó entra se você mandar: a última linha,spring.config.import=optional:file:.env[.properties], diz ao Spring para ler o arquivo.envcomo se fosse um.properties. Ooptional:evita erro quando o arquivo não existe (no servidor, por exemplo), e o caminho é relativo a onde você roda o comando.The.envonly comes in if you tell it to: the last line,spring.config.import=optional:file:.env[.properties], tells Spring to read the.envfile as if it were a.properties. Theoptional:prevents an error when the file does not exist (on a server, say), and the path is relative to where you run the command.
Medi cada caso com o jar, trocando só a origem do valor. A coluna do meio é o que o /config devolveu em greeting:I measured each case with the jar, changing only where the value came from. The middle column is what /config returned in greeting:
| SituaçãoSituation | greeting | LiçãoLesson |
|---|---|---|
| nada definidonothing set | valor do application.propertiesvalue from application.properties | o padrãothe default |
SPRING_PROFILES_ACTIVE=dev | “do perfil dev”“from the dev profile” | o arquivo do perfil sobrepõe o basethe profile file overrides the base |
APP_GREETING=do-ambiente | do-ambiente | o ambiente sobrepõe o arquivothe environment overrides the file |
ambiente + perfil devenvironment + dev profile | do-ambiente | ambiente vence até o arquivo do perfilenvironment beats even the profile file |
--app.greeting=do-argumento + ambienteenvironment | do-argumento | o argumento de linha de comando vence tudothe command-line argument beats everything |
.env com app.greeting=do-dotenv | do-dotenv | funciona: o nome é o da propriedadeworks: the name is the property's |
.env eand APP_GREETING=do-ambiente | do-ambiente | o ambiente vence o .envthe environment beats the .env |
.env comwith APP_GREETING=x (estilo shell)(shell style) | valor do application.propertiesvalue from application.properties | não surtiu efeito!had no effect! |
o mesmo .env + ${APP_GREETING:…} no .propertiesthe same .env + ${APP_GREETING:…} in the .properties | veio-do-dotenv | o placeholder resolvethe placeholder resolves |
.env no Spring. O spring.config.import carrega o .env como um arquivo de propriedades, e as chaves ficam literais. A conversão automática de APP_GREETING em app.greeting vale para variáveis de ambiente do sistema, não para chaves de um arquivo. Então: (a) escreva no .env os nomes das propriedades (app.greeting=…), ou (b) mantenha o estilo shell no .env e referencie com ${APP_GREETING:padrão} no application.properties. E, como o Docker e o shell exportam APP_GREETING de verdade, essa mesma chave passa a valer por fora, sem mudar nada.The .env trap in Spring. spring.config.import loads the .env as a properties file, and the keys stay literal. The automatic conversion of APP_GREETING into app.greeting applies to system environment variables, not to keys in a file. So: (a) write the property names in the .env (app.greeting=…), or (b) keep the shell style in the .env and reference it with ${APP_GREETING:default} in application.properties. And since Docker and the shell really export APP_GREETING, that same key then works from outside, with nothing changed..properties. Escrevi “olá” em UTF-8 no application.properties e a resposta veio “olá”: o arquivo foi lido como Latin-1, o padrão dos .properties. Com o escape olá a resposta saiu correta (“olá”). Para textos com acento, use o escape ou o formato YAML. Não reproduzi com o application.yml; a referência é que decide.Accents in .properties. I wrote “olá” in UTF-8 in application.properties and the response came back “olá”: the file was read as Latin-1, the .properties default. With the escape olá the response was correct (“olá”). For text with accents, use the escape or the YAML format. I did not reproduce it with application.yml; the reference is what decides.Perfis: dev, prod e o que sãoProfiles: dev, prod and what they are
Um perfil (profile) é um nome de ambiente que escolhe quais arquivos de configuração entram. O arquivo base, application.properties, vale sempre. Se o perfil dev está ativo, o Spring também lê application-dev.properties e os valores dele sobrepõem os do base. Nada mais: um perfil não é uma opção mágica do Java, é só a convenção de nome application-{perfil}.properties.A profile is an environment name that picks which configuration files come in. The base file, application.properties, always applies. If the dev profile is active, Spring also reads application-dev.properties and its values override the base ones. That is all: a profile is not a Java magic option, just the naming convention application-{profile}.properties.
app.greeting=olá do perfil dev
logging.level.demo=DEBUG
app.greeting=olá do perfil prod
logging.level.root=WARN
| Como ativarHow to activate | ExemploExample |
|---|---|
| Variável de ambiente (a mais usada em Docker)Environment variable (the most used in Docker) | SPRING_PROFILES_ACTIVE=prod |
| Argumento do jarJar argument | java -jar app.jar --spring.profiles.active=prod |
| Maven em modo devMaven in dev mode | ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev |
| Vários perfis (o último vence)Several profiles (last wins) | SPRING_PROFILES_ACTIVE=prod,live |
Testei o ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev (a resposta veio do arquivo do perfil dev) e a ativação por variável de ambiente, nas duas formas com o jar. Se nenhum perfil for ativado, o Spring usa o perfil default, e o log avisa: No active profile set, falling back to 1 default profile: "default". Dois cuidados: (1) use perfis para o que muda de verdade entre ambientes (nível de log, endereço do banco); segredo não vai em arquivo de perfil dentro do jar, e sim em variável de ambiente; (2) um jar que contém application-dev.properties e application-prod.properties é o mesmo jar nos dois ambientes, e quem escolhe é o perfil ativo na hora de rodar. Também existe o formato de um arquivo só com várias seções (#--- no .properties, --- no YAML) com spring.config.activate.on-profile.I tested ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev (the response came from the dev profile file) and activation by environment variable, in both forms with the jar. If no profile is activated, Spring uses the default profile, and the log says so: No active profile set, falling back to 1 default profile: "default". Two cautions: (1) use profiles for what really changes between environments (log level, database address); a secret does not go in a profile file inside the jar, it goes in an environment variable; (2) a jar that contains application-dev.properties and application-prod.properties is the same jar in both environments, and the profile active at run time chooses. There is also a single-file format with several sections (#--- in .properties, --- in YAML) with spring.config.activate.on-profile.
Rodar e executar com DockerRunning and running with Docker
# dev (compila e roda; o .env da pasta atual é lido)
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# "produção" sem Docker: gerar o jar e rodar
./mvnw -DskipTests package
SPRING_PROFILES_ACTIVE=prod APP_GREETING=oi java -jar target/hello-0.0.1.jar
No Docker, o build em duas etapas separa o JDK pesado (que tem o Maven) do JRE que roda o jar. O --mount=type=cache guarda o ~/.m2 entre builds; sem ele, cada mudança no código baixaria tudo de novo.In Docker, the two-stage build separates the heavy JDK (which has Maven) from the JRE that runs the jar. The --mount=type=cache keeps ~/.m2 between builds; without it, each code change would download everything again.
# ---- etapa 1: compila com Maven (JDK completo) ----
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /src
COPY pom.xml .
# o cache do ~/.m2 sobrevive entre builds; sem ele, todo build baixa o mundo de novo
RUN --mount=type=cache,target=/root/.m2 mvn -q -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -q -B -DskipTests package
# ---- etapa 2: só o JRE e o jar ----
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /src/target/hello-0.0.1.jar app.jar
USER 10001
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
docker build -t lang-java .
docker run --rm -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e APP_GREETING="olá do contêiner" \
-e APP_DB_PASSWORD=segredo \
lang-java
curl localhost:8080/config
Resultado medido: imagem de 517 MB; a resposta do contêiner trouxe o greeting da variável, o usuário do processo era 10001 (não root) e o docker stop terminou em menos de 1 s. Duas armadilhas reais: o ENTRYPOINT é o java -jar, então docker run lang-java sh -c "…" não abre um shell, passa os argumentos ao app (me custou um contêiner esquecido rodando; para abrir um shell use --entrypoint sh). E a imagem JRE não traz curl nem wget, então o HEALTHCHECK “clássico” não funciona nela. A saída que testei está no Compose da seção de Docker: o bash da imagem abre a conexão sozinho (/dev/tcp) e consulta /actuator/health.Measured result: a 517 MB image; the container's response carried the greeting from the variable, the process user was 10001 (not root) and docker stop finished in under 1 s. Two real traps: the ENTRYPOINT is java -jar, so docker run lang-java sh -c "…" does not open a shell, it passes the arguments to the app (it cost me a forgotten running container; to open a shell use --entrypoint sh). And the JRE image carries neither curl nor wget, so the “classic” HEALTHCHECK does not work in it. The way out that I tested is in the Compose file in the Docker section: the image's bash opens the connection itself (/dev/tcp) and queries /actuator/health.
Python com FastAPIPython with FastAPI
O que costuma atrapalhar quem está aprendendoWhat usually trips up learners
Python é fácil de escrever e difícil de preparar. Quatro tropeços vêm sempre, e reproduzi os principais no Ubuntu 24.04:Python is easy to write and hard to set up. Four stumbles come up every time, and I reproduced the main ones on Ubuntu 24.04:
pythonnão existe, sópython3. No Ubuntu o comando épython3, e opippode nem estar instalado (aqui no meu computador não está; ele vem no pacotepython3-pip). Dentro de uma.venvativada,pythonpassa a existir.pythondoes not exist, onlypython3. On Ubuntu the command ispython3, andpipmay not even be installed (on my computer here it is not; it comes in thepython3-pippackage). Inside an activated.venv,pythonstarts to exist.pip installglobal dá erro. No Ubuntu 24.04,pip install requestsrespondeuerror: externally-managed-environment. O Python do sistema é gerenciado peloapte o PEP 668 o protege de proposital. Não use--break-system-packages: crie um ambiente virtual.A globalpip installerrors. On Ubuntu 24.04,pip install requestsanswerederror: externally-managed-environment. The system Python is managed byaptand PEP 668 protects it on purpose. Do not use--break-system-packages: create a virtual environment.- O ambiente virtual (
venv) precisa de um pacote a parte.python3 -m venvfalhou com “ensurepip is not available… install the python3-venv package” até eu instalarpython3-venv. Depois funcionou.The virtual environment (venv) needs a separate package.python3 -m venvfailed with “ensurepip is not available… install the python3-venv package” until I installedpython3-venv. After that it worked. - Ferramentas demais.
pip,venv,pyenv,pip-tools,poetry,pipx: cada uma resolve um pedaço. O uv junta tudo numa só: instala o próprio Python, cria a.venv, resolve e trava dependências e roda comandos. É o que uso na nota.Too many tools.pip,venv,pyenv,pip-tools,poetry,pipx: each solves a piece. uv joins them in one: it installs Python itself, creates the.venv, resolves and locks dependencies and runs commands. It is what I use in the note.
uv: ambiente, dependências e execuçãouv: environment, dependencies and execution
O uv segue o padrão do pyproject.toml. A ideia: você declara o que o projeto precisa e o uv produz o resto. Um .venv é só uma pasta com um python e os pacotes deste projeto, então “ativar” o ambiente é opcional: o uv run já usa a .venv certa, criando-a se faltar.uv follows the pyproject.toml standard. The idea: you declare what the project needs and uv produces the rest. A .venv is just a folder with a python and the packages for this project, so “activating” the environment is optional: uv run already uses the right .venv, creating it if missing.
[project]
name = "hello"
version = "0.0.1"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.162",
"pydantic-settings>=2.16",
]
| ComandoCommand | O que faz (conferido)What it does (checked) |
|---|---|
uv init --bare | Cria só o pyproject.toml.Creates only the pyproject.toml. |
uv add "fastapi[standard]" | Escreve a dependência no pyproject.toml, gera o uv.lock e cria a .venv com tudo instalado. Foi o que vi com o httpx.Writes the dependency into pyproject.toml, generates uv.lock and creates the .venv with everything installed. It is what I saw with httpx. |
uv sync | Faz a .venv bater com o lock. Use --frozen para não alterar o lock (CI e Docker).Makes the .venv match the lock. Use --frozen to not change the lock (CI and Docker). |
uv run <comando> | Roda o comando dentro da .venv do projeto.Runs the command inside the project's .venv. |
uv python install 3.12 | Baixa um Python para ~/.local/share/uv/python, sem tocar no do sistema.Downloads a Python into ~/.local/share/uv/python, without touching the system one. |
uv cache dir / uv cache clean | Mostra (~/.cache/uv) e limpa o cache de pacotes.Shows (~/.cache/uv) and clears the package cache. |
Um achado que explica muita confusão: dentro do contêiner do uv, python -c "import fastapi" deu ModuleNotFoundError, enquanto .venv/bin/python e uv run python funcionaram. O python “solto” é o do sistema; os pacotes do projeto só existem no Python da .venv. Se o editor ou o terminal mostra “módulo não encontrado” para algo que você acabou de instalar, quase sempre é o interpretador errado.A finding that explains a lot of confusion: inside the uv container, python -c "import fastapi" gave ModuleNotFoundError, while .venv/bin/python and uv run python worked. The “loose” python is the system one; the project's packages only exist in the .venv's Python. If the editor or terminal shows “module not found” for something you just installed, it is almost always the wrong interpreter.
FastAPI: como funcionaFastAPI: how it works
O FastAPI é um framework web baseado em anotações de tipo: você declara uma função e o tipo dos parâmetros, e ele valida a entrada (via pydantic), converte e gera a documentação OpenAPI sozinho, em /docs (Swagger UI). Ele fala o protocolo ASGI, que precisa de um servidor: o uvicorn, que o extra fastapi[standard] instala. O comando fastapi (da CLI incluída) escolhe o servidor por você.FastAPI is a web framework based on type annotations: you declare a function and the parameter types, and it validates the input (via pydantic), converts it and generates the OpenAPI documentation by itself, at /docs (Swagger UI). It speaks the ASGI protocol, which needs a server: uvicorn, which the fastapi[standard] extra installs. The fastapi command (from the bundled CLI) picks the server for you.
from fastapi import FastAPI
from app.settings import settings
app = FastAPI(title="hello")
@app.get("/config")
def config():
return {
"env": settings.app_env,
"greeting": settings.greeting,
"dbPasswordSet": bool(settings.db_password),
}
@app.get("/health")
def health():
return {"status": "UP"}
fastapi dev não é alcançável de fora do contêiner. Rodei os dois num contêiner com a porta publicada. O fastapi dev (recarrega ao salvar) escuta em 127.0.0.1 e a requisição do host ficou sem resposta; o fastapi run escuta em 0.0.0.0 e respondeu {"status":"UP"}. Use dev no seu computador e run em contêiner e produção.fastapi dev is not reachable from outside the container. I ran both in a container with the port published. fastapi dev (reloads on save) listens on 127.0.0.1 and the request from the host got no answer; fastapi run listens on 0.0.0.0 and answered {"status":"UP"}. Use dev on your computer and run in a container and in production.Variáveis de ambiente e o .envEnvironment variables and the .env
O Python expõe as variáveis de ambiente em os.environ, mas não lê o .env: com um .env contendo GREETING, os.environ.get("GREETING") devolveu None. A biblioteca pydantic-settings resolve os dois problemas (ler o .env e converter os tipos) com uma classe:Python exposes environment variables in os.environ, but does not read the .env: with a .env containing GREETING, os.environ.get("GREETING") returned None. The pydantic-settings library solves both problems (reading the .env and converting types) with a class:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# lê o .env (se existir) e as variáveis de ambiente; o ambiente vence o .env
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "dev" # APP_ENV
greeting: str = "olá do código" # GREETING
db_password: str = "" # DB_PASSWORD
settings = Settings()
| SituaçãoSituation | greeting | db_password |
|---|---|---|
| nada definidonothing set | olá do códigoolá do código | vazioempty |
GREETING=ambiente | ambiente | vazioempty |
só .env com GREETING, DB_PASSWORD e uma chave OUTRA.env only, with GREETING, DB_PASSWORD and a key OUTRA | do-dotenv | segredo |
.env eand GREETING=ambiente | ambiente | segredo |
greeting=minusc (minúsculas no ambiente)(lowercase in the environment) | minusc | segredo |
- O nome do campo (
db_password) casa com a variávelDB_PASSWORDsem diferenciar maiúsculas (o último caso da tabela). O ambiente vence o.env, e o.envvence o padrão da classe, a mesma escada do começo.The field name (db_password) matches theDB_PASSWORDvariable case-insensitively (the last row of the table). The environment beats the.env, and the.envbeats the class default, the same ladder as in the beginning. env_file=".env"é relativo a onde você roda o comando, não a onde está o arquivo Python. Rodar de outra pasta faz o.env“sumir” sem erro.env_file=".env"is relative to where you run the command, not to where the Python file is. Running from another folder makes the.env“vanish” with no error.- Com
extra="ignore"as chaves do.envque a classe não declara (comoOUTRA) são ignoradas, em vez de dar erro.Withextra="ignore"the.envkeys the class does not declare (likeOUTRA) are ignored instead of raising an error.
Rodar e executar com DockerRunning and running with Docker
# preparar o ambiente a partir do pyproject + uv.lock
uv sync --frozen
# dev (recarrega ao salvar; só acessível de 127.0.0.1)
uv run fastapi dev app/main.py
# produção (escuta em 0.0.0.0)
GREETING=oi uv run fastapi run app/main.py --port 8000
# ---- etapa 1: resolve e instala as dependências com uv ----
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS build
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY app ./app
# ---- etapa 2: só o Python, a .venv e o código ----
FROM python:3.12-slim-bookworm
WORKDIR /app
COPY --from=build /app/.venv /app/.venv
COPY --from=build /app/app /app/app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
USER 10001
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \
CMD ["python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health')"]
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
docker build -t lang-py .
docker run --rm -p 8000:8000 \
-e GREETING="olá do contêiner" -e DB_PASSWORD=segredo \
lang-py
curl localhost:8000/config
Medi: imagem de 305 MB; o contêiner ficou healthy pelo HEALTHCHECK (feito com o próprio Python, já que a imagem não tem curl); o processo roda como o usuário 10001; o uv não está na imagem final. Três pontos que quebram builds: (1) a .venv guarda caminhos absolutos, então ela precisa ficar no mesmo caminho (/app/.venv) nas duas etapas, e o Python das duas imagens tem de ser a mesma versão; (2) sem o .dockerignore listando .venv, a .venv do seu computador (de outro sistema) seria copiada para a imagem; (3) --frozen garante que o build use exatamente o uv.lock.Measured: a 305 MB image; the container became healthy through the HEALTHCHECK (made with Python itself, since the image has no curl); the process runs as user 10001; uv is not in the final image. Three points that break builds: (1) the .venv stores absolute paths, so it must sit at the same path (/app/.venv) in both stages, and the Python in both images must be the same version; (2) without a .dockerignore listing .venv, your computer's .venv (from another system) would be copied into the image; (3) --frozen guarantees the build uses exactly uv.lock.
TypeScript com NodeTypeScript with Node
O que é cada peçaWhat each piece is
A confusão aqui é que “TypeScript” e “Node” são coisas diferentes. Node.js é o runtime que executa JavaScript fora do navegador. TypeScript é JavaScript com tipos, e os tipos não existem na execução: servem para conferir o código antes. npm é o gerenciador de pacotes que vem com o Node. Por muitos anos a regra era “compile o .ts para .js e rode o .js”, com o tsc (ou ferramentas como ts-node e tsx). Hoje o Node 24 também roda .ts direto, e isso muda o desenho:The confusion here is that “TypeScript” and “Node” are different things. Node.js is the runtime that executes JavaScript outside the browser. TypeScript is JavaScript with types, and types do not exist at run time: they serve to check the code beforehand. npm is the package manager that comes with Node. For many years the rule was “compile the .ts to .js and run the .js”, with tsc (or tools like ts-node and tsx). Today Node 24 also runs .ts directly, and that changes the picture:
- O Node roda
.ts“apagando” os tipos, sem conferi-los. Testei um arquivo comconst x: number = "texto": o Node executou normalmente, com o erro de tipo. Quem acusa o erro é otsc --noEmit. Por isso o projeto tem o scripttypecheck, que deve rodar no CI e no editor.Node runs.tsby “erasing” the types, without checking them. I tested a file withconst x: number = "texto": Node ran it normally, type error and all. What reports the error istsc --noEmit. That is why the project has atypecheckscript, which should run in CI and in the editor. - Só vale sintaxe “apagável”. Um
enumprecisa de código gerado, e o Node recusou comERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. A opçãoerasableSyntaxOnlydotsconfig.jsonexiste para otscavisar disso no editor, antes de rodar.Only “erasable” syntax is valid. Anenumneeds generated code, and Node refused withERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. TheerasableSyntaxOnlyoption intsconfig.jsonexists sotscwarns you in the editor, before running. - Em produção ainda se compila. O
tscgera odist/*.jse você rodanode dist/server.js. É mais previsível e não depende do recurso de apagar tipos. A documentação do Node descreve os limites do modo nativo.In production you still compile.tscemitsdist/*.jsand you runnode dist/server.js. It is more predictable and does not depend on the type-stripping feature. The Node documentation describes the limits of the native mode.
npm: package.json, lock e node_modulesnpm: package.json, lock and node_modules
{
"name": "hello",
"version": "0.0.1",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"dev": "node --watch --env-file-if-exists=.env src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^7.0.2"
}
}
dependenciesvão para a produção (aqui, ozod).devDependenciessão só para desenvolver e compilar (typescript,@types/node). No Docker final,npm ci --omit=devdeixa as segundas de fora.dependenciesgo to production (here,zod).devDependenciesare only for developing and compiling (typescript,@types/node). In the final Docker image,npm ci --omit=devleaves the second group out."type": "module"faz o Node tratar os.jscomo módulos ES (import). Sem isso, ele esperarequire(CommonJS). É um dos erros mais comuns: “Cannot use import statement outside a module”."type": "module"makes Node treat.jsfiles as ES modules (import). Without it, it expectsrequire(CommonJS). It is one of the most common errors: “Cannot use import statement outside a module”.package-lock.jsonfixa as versões exatas, como ouv.lock.npm installpode alterar o lock;npm ciapaga onode_modulese instala exatamente o lock, e falha se o lock e opackage.jsondivergirem. Usenpm cino CI e no Docker.package-lock.jsonpins exact versions, likeuv.lock.npm installmay change the lock;npm cideletesnode_modulesand installs exactly the lock, and fails if the lock andpackage.jsondisagree. Usenpm ciin CI and Docker.npx <ferramenta>roda um programa instalado no projeto (ou baixa um temporário).npm run <script>roda um item descripts. A versão do Node pode ser fixada emengines; ferramentas comonvmefnmtrocam de versão por projeto.npx <tool>runs a program installed in the project (or downloads a temporary one).npm run <script>runs an item fromscripts. The Node version can be pinned inengines; tools likenvmandfnmswitch versions per project.
tsconfig.json: as opções que importamtsconfig.json: the options that matter
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
| OpçãoOption | Para que serveWhat it is for |
|---|---|
module / moduleResolution: nodenext | Segue as regras de módulos do Node moderno (ESM com extensão nos import).Follows modern Node's module rules (ESM with extensions in import). |
rewriteRelativeImportExtensions | Você escreve import "./env.ts" (que o Node roda direto) e o tsc reescreve para "./env.js" no dist/. Conferi no arquivo gerado.You write import "./env.ts" (which Node runs directly) and tsc rewrites it to "./env.js" in dist/. I checked the generated file. |
erasableSyntaxOnly, verbatimModuleSyntax | Mantêm o código dentro do que o Node consegue rodar apagando tipos.Keep the code within what Node can run by erasing types. |
strict | Liga as conferências mais úteis (null, tipos implícitos). Deixe ligado.Turns on the most useful checks (null, implicit types). Leave it on. |
outDir / rootDir | De onde o tsc lê (src) e onde grava (dist).Where tsc reads from (src) and writes to (dist). |
types: ["node"] | Carrega os tipos do @types/node (process, node:http). Sem eles, o tsc acusa que process não existe.Loads the types from @types/node (process, node:http). Without them, tsc complains that process does not exist. |
Uma pegadinha do tsc: rodar tsc arquivo.ts ignora o tsconfig.json; a versão 7.0.2 que usei chega a recusar (error TS5112) sugerindo --ignoreConfig. Para usar a configuração do projeto, rode só tsc.A tsc trap: running tsc file.ts ignores tsconfig.json; the 7.0.2 version I used even refuses (error TS5112) suggesting --ignoreConfig. To use the project's configuration, run just tsc.
Variáveis de ambiente e o .envEnvironment variables and the .env
No Node as variáveis ficam em process.env, um objeto de string | undefined. Desde o Node 20.6 existe a opção --env-file, que lê um arquivo .env sem biblioteca nenhuma (o antigo pacote dotenv deixou de ser necessário para o caso simples). Como tudo chega como texto, o zod valida e converte na partida:In Node, variables live in process.env, an object of string | undefined. Since Node 20.6 there is the --env-file option, which reads a .env file with no library at all (the old dotenv package is no longer needed for the simple case). Since everything arrives as text, zod validates and converts at startup:
import { z } from "zod";
// process.env é Record<string, string | undefined>: tudo chega como texto.
// O zod valida e converte uma vez, na partida; se faltar algo, o app nem sobe.
const Env = z.object({
PORT: z.coerce.number().int().default(3000),
GREETING: z.string().default("olá do código"),
DB_PASSWORD: z.string().default(""),
});
export const env = Env.parse(process.env);
| Comando (medido)Command (measured) | ResultadoResult |
|---|---|
PORT=7041 node src/server.ts com um .env ao ladowith a .env next to it | o .env é ignorado: greeting = padrão do códigothe .env is ignored: greeting = code default |
node --env-file=.env src/server.ts | lê o .env: greeting = do-dotenvreads the .env: greeting = do-dotenv |
GREETING=ambiente node --env-file=.env … | o ambiente vence: greeting = ambientethe environment wins: greeting = ambiente |
--env-file=.env sem o arquivowithout the file | erro node: .env: not found e o processo terminaerror node: .env: not found and the process exits |
--env-file-if-exists=.env sem o arquivowithout the file | segue em frente com os padrões (é o que o npm run dev usa)carries on with the defaults (it is what npm run dev uses) |
PORT=abc node src/server.ts | o zod recusa e o app nem sobezod refuses and the app does not even start |
Repare no primeiro caso: ter um .env na pasta não basta, a opção precisa estar no comando. É o mesmo princípio de antes. Em produção e no Docker, prefira nem usar o arquivo e passar as variáveis por fora.Notice the first case: having a .env in the folder is not enough, the option must be in the command. It is the same principle as before. In production and Docker, prefer not to use the file at all and pass the variables from outside.
Rodar e executar com DockerRunning and running with Docker
# instalar exatamente o que o lock manda
npm ci
# dev: roda o .ts direto, recarrega ao salvar, lê o .env se existir
npm run dev
# conferir tipos / compilar / rodar o compilado
npm run typecheck
npm run build
GREETING=oi npm start
import { createServer } from "node:http";
import { env } from "./env.ts";
const server = createServer((req, res) => {
const send = (status: number, body: unknown) => {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
};
if (req.url === "/health") return send(200, { status: "UP" });
if (req.url === "/config") {
return send(200, { greeting: env.GREETING, dbPasswordSet: env.DB_PASSWORD !== "" });
}
send(404, { error: "not found" });
});
server.listen(env.PORT, "0.0.0.0", () => console.log(`ouvindo em :${env.PORT}`));
// o Docker manda SIGTERM no "docker stop"; sem isso o Node espera 10 s e leva SIGKILL
process.on("SIGTERM", () => server.close(() => process.exit(0)));
# ---- etapa 1: instala tudo e compila o TypeScript ----
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ---- etapa 2: só as dependências de produção e o JS compilado ----
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]
docker build -t lang-ts .
docker run --rm -p 3000:3000 \
-e GREETING="olá do contêiner" -e DB_PASSWORD=segredo \
lang-ts
curl localhost:3000/config
Medi: imagem de 343 MB, contêiner healthy, usuário node (não root), e o docker stop terminou em 0,33 s. Esse tempo curto é por causa da linha process.on("SIGTERM", …) do server.ts: o Docker avisa com SIGTERM e, sem tratador, o Node como PID 1 pode ignorar o sinal e levar um SIGKILL depois do prazo. Não testei a versão sem o tratador, então trate isso como boa prática e não como número medido. A etapa final só instala o que é de produção (--omit=dev): o typescript fica na etapa de build. O npm run dev em si eu não executei; testei os mesmos comandos node que ele chama.Measured: a 343 MB image, healthy container, user node (not root), and docker stop finished in 0.33 s. That short time is thanks to the process.on("SIGTERM", …) line in server.ts: Docker signals with SIGTERM and, without a handler, Node as PID 1 may ignore the signal and take a SIGKILL after the grace period. I did not test the version without the handler, so treat that as good practice, not a measured number. The final stage installs only production packages (--omit=dev): typescript stays in the build stage. I did not run npm run dev itself; I tested the same node commands it calls.
Docker: o mesmo desenho nas trêsDocker: the same shape in all three
Como você previu, o Docker deixa as três pilhas parecidas: uma etapa de build com as ferramentas pesadas, uma etapa final só com o runtime e o artefato, e a configuração entrando de fora, na hora de rodar. O que muda é o que se copia entre as etapas: um jar, uma .venv, uma pasta dist/ com node_modules de produção. Para fundamentos (imagem, camadas, redes, volumes), veja a nota de Docker.As you predicted, Docker makes the three stacks look alike: a build stage with the heavy tools, a final stage with only the runtime and the artifact, and the configuration coming in from outside, at run time. What changes is what gets copied between stages: a jar, a .venv, a dist/ folder with production node_modules. For fundamentals (image, layers, networks, volumes), see the Docker note.
| MedidoMeasured | Java | Python | TypeScript |
|---|---|---|---|
| Tamanho da imagem finalFinal image size | 517 MB | 305 MB | 343 MB |
| Usuário do processoProcess user | 10001 | 10001 | node |
| HealthcheckHealthcheck | bash + /dev/tcp (sem curl)bash + /dev/tcp (no curl) | Python + urllibPython + urllib | Node + fetchNode + fetch |
| Porta no ComposePort in Compose | 8080 | 8000 | 3000 |
O Compose abaixo sobe as três de uma vez, cada uma com o env_file e a mesma checagem de saúde. Rodei com docker compose up -d --build --wait: os três serviços ficaram healthy e cada /config respondeu com o valor certo (a do Java, "olá do compose", veio do environment: e saiu com o acento certo, porque variável de ambiente não passa pelo problema do Latin-1). Depois, docker compose down -v.The Compose file below brings all three up at once, each with its env_file and the same health check. I ran it with docker compose up -d --build --wait: the three services became healthy and each /config answered with the right value (the Java one, "olá do compose", came from environment: and came out with the right accent, because an environment variable does not go through the Latin-1 problem). Afterwards, docker compose down -v.
services:
java:
build: ./java
ports: ["8080:8080"]
environment:
SPRING_PROFILES_ACTIVE: prod
APP_GREETING: "olá do compose"
env_file: [.env]
healthcheck:
# a imagem JRE não tem curl; o bash do Ubuntu abre a conexão sozinho
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /actuator/health HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q UP <&3"]
interval: 10s
start_period: 20s
python:
build: ./py
ports: ["8000:8000"]
env_file: [.env]
node:
build: ./ts
ports: ["3000:3000"]
env_file: [.env]
Armadilhas que valem nas trêsTraps that hold in all three
- Escute em
0.0.0.0, não emlocalhost. Dentro do contêiner,127.0.0.1é só o próprio contêiner. Ofastapi devfalhou por isso; o Spring, ofastapi rune olisten(…, "0.0.0.0")do Node funcionaram.Listen on0.0.0.0, notlocalhost. Inside the container,127.0.0.1is only the container itself.fastapi devfailed because of that; Spring,fastapi runand Node'slisten(…, "0.0.0.0")worked. EXPOSEnão publica porta. Quem abre a porta para o seu computador é o-p host:contêiner(ouports:no Compose). OEXPOSEé só documentação.EXPOSEdoes not publish a port. What opens the port to your computer is-p host:container(orports:in Compose).EXPOSEis only documentation.docker run --env-filenão é um parser de shell. ComGREETING="com aspas"o valor chegou ao app com as aspas ("com aspas"), eDB_PASSWORD=x # comentariochegou comox # comentario. Oenv_filedo Compose, ao contrário, tirou as aspas e o comentário (conferi comdocker compose config). Escreva o.envsem aspas e sem comentário na mesma linha, e ele serve aos dois.docker run --env-fileis not a shell parser. WithGREETING="com aspas"the value reached the app with the quotes ("com aspas"), andDB_PASSWORD=x # comentarioarrived asx # comentario. Compose'senv_file, by contrast, stripped the quotes and the comment (I checked withdocker compose config). Write the.envwith no quotes and no comment on the same line, and it works for both..dockerignoreevita três desastres: copiar o seu.env(segredos na imagem), copiar a.venvou onode_modulesdo seu computador (outro sistema, outro caminho) e invalidar o cache de camadas a cada mudança..dockerignoreavoids three disasters: copying your.env(secrets in the image), copying your computer's.venvornode_modules(another system, another path) and invalidating the layer cache on every change.- Copie o manifesto antes do código. Os três Dockerfiles copiam
pom.xml,pyproject.toml/uv.lockoupackage*.jsonprimeiro e instalam as dependências; só então copiam o código. Mudar uma linha de código não refaz o download das bibliotecas.Copy the manifest before the code. The three Dockerfiles copypom.xml,pyproject.toml/uv.lockorpackage*.jsonfirst and install the dependencies; only then do they copy the code. Changing one line of code does not redo the library download. - Rode como usuário comum (
USER 10001ouUSER node) e deixe segredos só nas variáveis em tempo de execução, nunca emENVdo Dockerfile nem emARG, que ficam no histórico da imagem.Run as a regular user (USER 10001orUSER node) and keep secrets only in run-time variables, never in a DockerfileENVorARG, which stay in the image history.
Limpando os trilhos: o que fica na sua máquinaCleaning the tracks: what stays on your machine
Cada ferramenta deixa coisas no seu computador, e a diferença entre “projeto que quebrou” e “projeto que só precisa recomeçar do zero” é saber onde elas ficam. A regra: tudo que nasce de um manifesto e de um lock é descartável. Apague sem medo e reconstrua. Os tamanhos abaixo são do meu computador e dos projetos do laboratório.Each tool leaves things on your computer, and the difference between “a broken project” and “a project that only needs to start over” is knowing where they are. The rule: everything born from a manifest and a lock is disposable. Delete it without fear and rebuild. The sizes below are from my computer and the lab projects.
| O queWhat | OndeWhere | Tamanho vistoSize seen | Como limpar (e o que acontece)How to clean (and what happens) |
|---|---|---|---|
| Java: saída do buildJava: build output | target/ | 22 MB | ./mvnw clean (o próximo build recria)(the next build recreates it) |
| Java: bibliotecasJava: libraries | ~/.m2/repository | 478 MB | apagar a pasta; tudo é baixado de novo no próximo builddelete the folder; everything is downloaded again on the next build |
| Java: Maven do wrapperJava: wrapper's Maven | ~/.m2/wrapper | — | apagar; o ./mvnw baixa a versão de novodelete; ./mvnw downloads the version again |
| Python: ambiente do projetoPython: project environment | .venv/ | ~70 MB | rm -rf .venv eand uv sync |
| Python: cache do uvPython: uv cache | ~/.cache/uv | — | uv cache clean (conferi: “Removed 204 files”)(I checked: “Removed 204 files”) |
| Python: bytecodePython: bytecode | __pycache__/ | pequenosmall | apagar; o Python recria. Já vai no .gitignore.delete; Python recreates it. Already in .gitignore. |
| Node: dependênciasNode: dependencies | node_modules/ | 42 MB (só 3 pacotesjust 3 packages) | rm -rf node_modules eand npm ci |
| Node: compiladoNode: compiled output | dist/ | pequenosmall | rm -rf dist eand npm run build |
| Node: cache global do npmNode: global npm cache | ~/.npm | 4,8 GB | npm cache clean --force |
| Docker | imagens, cache de buildimages, build cache | 14 GB recuperáveis na minha máquina14 GB reclaimable on my machine | docker system df para ver; remova as imagens do laboratório pelo nometo see; remove the lab images by name |
O caso do ~/.npm é típico: 4,8 GB de cache acumulado de todos os projetos que já passaram pela máquina, que nunca atrapalha e nunca é limpo. Dois cuidados: rm -rf ~/.m2 apaga também o settings.xml (credenciais e espelhos do Maven), se existir, então apague só repository; e nunca apague a pasta de dados de um banco só porque estava no mesmo lugar de um cache.The ~/.npm case is typical: 4.8 GB of cache piled up from every project that ever went through the machine, which never hurts and is never cleaned. Two cautions: rm -rf ~/.m2 also deletes settings.xml (Maven credentials and mirrors), if there is one, so delete only repository; and never delete a database's data folder just because it sat next to a cache.
Quando algo quebra: sintoma, causa, saídaWhen something breaks: symptom, cause, way out
| SintomaSymptom | Causa provávelLikely cause | SaídaWay out |
|---|---|---|
javac: command not found | só o JRE instaladoonly the JRE installed | instalar o JDK; conferir JAVA_HOMEinstall the JDK; check JAVA_HOME |
Variável do .env não surte efeito (Spring)A .env variable has no effect (Spring) | chave literal, sem conversãoliteral key, no conversion | app.greeting=… no .env ouin the .env, or ${APP_GREETING:x} |
| “olá” no lugar de “olá” (Spring)“olá” instead of “olá” (Spring) | .properties lido como Latin-1.properties read as Latin-1 | escape á, ou valor vindo de variável de ambientethe á escape, or a value coming from an environment variable |
| Porta 8080 já em usoPort 8080 already in use | outro processo ou um contêiner esquecidoanother process or a forgotten container | ss -ltnp | grep 8080; docker ps; --server.port=… |
externally-managed-environment | pip no Python do sistema (PEP 668)pip on the system Python (PEP 668) | usar uv (ou python3 -m venv)use uv (or python3 -m venv) |
ModuleNotFoundError | interpretador errado (não é o da .venv)wrong interpreter (not the .venv's) | uv run … ouor .venv/bin/python |
| FastAPI no Docker sem respostaFastAPI in Docker, no answer | fastapi dev escuta em 127.0.0.1listens on 127.0.0.1 | fastapi run |
| “Cannot use import statement outside a module” | falta "type": "module"missing "type": "module" | acrescentar ao package.jsonadd it to package.json |
.env ignorado no Node.env ignored in Node | faltou a opçãothe option is missing | --env-file=.env |
| O código roda mas o editor mostra erro de tipoThe code runs but the editor shows a type error | o Node não confere tiposNode does not check types | npm run typecheck |
Arquivos do projeto com dono rootProject files owned by root | contêiner rodou como root com a pasta montadacontainer ran as root with the folder mounted | docker run -u "$(id -u):$(id -g)" -e HOME=/tmp … |
O último caso aconteceu comigo: o primeiro mvn package em contêiner deixou o target/ com dono root. Repeti com -u "$(id -u):$(id -g)" e o dono voltou a ser o meu usuário.The last case happened to me: the first mvn package in a container left target/ owned by root. I repeated it with -u "$(id -u):$(id -g)" and the owner went back to being my user.
Cola e revisãoCheat sheet and review
ColaCheat sheet
| TarefaTask | Java + Spring Boot | Python + FastAPI | TypeScript + Node |
|---|---|---|---|
| Ver a versãoCheck version | java -version | python3 --version | node -v |
| Instalar dependênciasInstall dependencies | ./mvnw package | uv sync --frozen | npm ci |
| Rodar em devRun in dev | ./mvnw spring-boot:run | uv run fastapi dev app/main.py | npm run dev |
| Gerar o artefatoBuild the artifact | ./mvnw package | (não precisa)(not needed) | npm run build |
| Rodar “produção”Run “production” | java -jar target/*.jar | uv run fastapi run app/main.py | node dist/server.js |
| Variável de ambienteEnvironment variable | APP_DB_PASSWORD → app.db-password | DB_PASSWORD → db_password | process.env.DB_PASSWORD |
Ler o .envRead the .env | spring.config.import=optional:file:.env[.properties] | env_file=".env" (pydantic-settings) | --env-file-if-exists=.env |
| AmbientesEnvironments | perfis: SPRING_PROFILES_ACTIVEprofiles: SPRING_PROFILES_ACTIVE | um campo como APP_ENVa field like APP_ENV | NODE_ENV |
| Limpar o projetoClean the project | ./mvnw clean | rm -rf .venv | rm -rf node_modules dist |
| DocumentaçãoDocumentation | docs.spring.io/spring-boot | fastapi.tiangolo.com | nodejs.org/docs |
Laboratório: reproduza em 15 minutosLab: reproduce in 15 minutes
- Crie as três pastas (
java/,py/,ts/) com os arquivos desta nota e rode./mvnw package,uv syncenpm ci. Anote o que cada um criou (target/,.venv/,node_modules/).Create the three folders (java/,py/,ts/) with the files in this note and run./mvnw package,uv syncandnpm ci. Note what each one created (target/,.venv/,node_modules/). - Em cada uma, preencha a tabela de precedência: rode sem nada, com variável de ambiente, com
.env, com os dois. Confirme que o ambiente vence o.enve que a senha nunca aparece na resposta.In each, fill in the precedence table: run with nothing, with an environment variable, with a.env, with both. Confirm that the environment beats the.envand that the password never shows up in the response. - Reproduza as três armadilhas: o
.envestilo shell no Spring, ofastapi devdentro do Docker e onode src/server.tssem--env-file.Reproduce the three traps: the shell-style.envin Spring,fastapi devinside Docker andnode src/server.tswithout--env-file. - Suba os três com
docker compose up -d --build --wait, confira os três/confige encerre comdocker compose down -v.Bring the three up withdocker compose up -d --build --wait, check the three/configand finish withdocker compose down -v. - Limpe: apague
target/,.venv/,node_modules/,dist/e remova as três imagens. Reconstrua um projeto do zero para provar que nada se perdeu.Clean up: deletetarget/,.venv/,node_modules/,dist/and remove the three images. Rebuild one project from scratch to prove nothing was lost.
mvn wrapper:wrapper, o ./mvnw spring-boot:run com perfil, as três imagens, o Compose com as três e as limpezas. Não executei: o npm run dev em si, o uv python install, a instalação do uv e do Maven pelo seu método oficial no computador, e o application.yml (só o .properties). Os números de tamanho são do meu computador e mudam com a versão das imagens-base.What was and was not run. I ran: the build and execution of the three apps (the jar with the computer's JDK 25; Python and uv in a container; Node on the computer and in a container), all the precedence tables, mvn wrapper:wrapper, ./mvnw spring-boot:run with a profile, the three images, the Compose file with the three and the cleanups. I did not run: npm run dev itself, uv python install, installing uv and Maven through their official method on the computer, and application.yml (only .properties). The size numbers are from my computer and change with the base-image version.Perguntas de revisãoReview questions
- Por que nenhuma das três linguagens lê o
.envsozinha? O que cada uma usa para lê-lo?Why does none of the three languages read the.envby itself? What does each use to read it? - Explique a escada de precedência. O que vence entre um argumento, uma variável de ambiente e um
.env?Explain the precedence ladder. What wins among an argument, an environment variable and a.env? - O que faz o Maven quando você roda
mvn package? Por que opom.xmlnão tem versão nas dependências do Spring?What does Maven do when you runmvn package? Why does thepom.xmlhave no version on the Spring dependencies? - O que é um perfil do Spring? Se
application-prod.propertiese uma variável de ambiente definem a mesma chave, qual vence?What is a Spring profile? Ifapplication-prod.propertiesand an environment variable define the same key, which wins? - Por que
APP_GREETING=xno.envnão funciona no Spring, mas funciona como variável de ambiente?Why doesAPP_GREETING=xin the.envnot work in Spring, but work as an environment variable? - Para que serve a
.venv? Por quepip installglobal falha no Ubuntu 24.04, e o que o uv muda?What is the.venvfor? Why does a globalpip installfail on Ubuntu 24.04, and what does uv change? - Qual a diferença entre
fastapi devefastapi run, e por que isso importa em Docker?What is the difference betweenfastapi devandfastapi run, and why does it matter in Docker? - O Node roda
.tssem conferir tipos. Quem confere? O que oerasableSyntaxOnlyevita?Node runs.tswithout checking types. What does check? What doeserasableSyntaxOnlyprevent? - Qual a diferença entre
npm installenpm ci? Qual vai no Dockerfile e por quê?What is the difference betweennpm installandnpm ci? Which goes in the Dockerfile and why? - O que é descartável em cada pilha e como se recria? O que você nunca apagaria?What is disposable in each stack and how do you recreate it? What would you never delete?
- Spring Boot: externalized configuration · System requirements · Spring Boot Maven plugin · Spring Initializr
- Maven: build lifecycle · Maven Wrapper
- uv · uv: Docker · PEP 668 · Writing pyproject.toml
- FastAPI CLI · FastAPI in containers · pydantic-settings
- Node.js: --env-file · Node.js: TypeScript · TSConfig reference · npm ci · Zod
- The Twelve-Factor App: config · Docker Compose: environment variables · Docker: multi-stage builds