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.

Java + Spring Boot JDK 25 javac + java Maven pom.xml target/hello.jar jar executável java -jar app.jar Tomcat embutido Python + FastAPI Python 3.12 python / python3 uv pyproject.toml + uv.lock .venv/ python + pacotes fastapi run uvicorn por baixo TypeScript + Node Node 24 node npm package.json + lock node_modules/ + dist/ pacotes + JS compilado node dist/server.js ou node src/server.ts
Cada linguagem repete o mesmo desenho: um runtime, uma ferramenta que gerencia dependências a partir de um manifesto, um artefato e um comando de início.Each language repeats the same shape: a runtime, a tool that manages dependencies from a manifest, an artifact and a start command.
PeçaPieceJava + Spring BootPython + FastAPITypeScript + Node
RuntimeRuntimeJDK 25 (java, javac)Python 3.12 (python3)Node 24 (node)
Gerenciador de dependências e buildDependency and build managerMaven (mvn ou ./mvnw)uv (uv)npm (npm)
Manifesto (o que o projeto pede)Manifest (what the project asks for)pom.xmlpyproject.tomlpackage.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 versionsuv.lockpackage-lock.json
Onde as dependências ficamWhere dependencies live~/.m2/repository (globalglobal).venv/ (por projetoper project)node_modules/ (por projetoper project)
Framework webWeb frameworkSpring Boot (Tomcat embutidoembedded)FastAPI (uvicorn)no lab, só node:httpin the lab, just node:http
Arquivo de configuraçãoConfiguration fileapplication.propertiesclasse Settings (+ .env)Settings class (+ .env)objeto validado (+ .env)validated object (+ .env)
Porta padrão do labLab default port808080003000

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.

1. Argumentos explícitos1. Explicit arguments --app.greeting=x · Settings(greeting=…) 2. Variáveis de ambiente2. Environment variables APP_GREETING=x (SO, Docker, CI) 3. Arquivo .env, se o runtime for mandado ler3. A .env file, if the runtime is told to read it config.import · env_file= · --env-file 4. Valores padrão4. Defaults application.properties · settings.py · env.ts o processo enxergathe process sees UM valor por nomeONE value per name mais forte ↑ / ↓ mais fracostronger ↑ / ↓ weaker
A mesma escada vale nas três linguagens: o que está acima vence o que está abaixo. Foi o que medimos em cada uma.The same ladder holds in all three languages: what is higher wins over what is lower. It is what we measured in each one.

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=oi afeta 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=oi affects 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=8080 chega 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=8080 arrives 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 .env real. Coloque .env no .gitignore e no .dockerignore, e versione um .env.example sem segredos. Um segredo que entrou no Git continua no histórico mesmo depois de apagado.Never commit the real .env. Put .env in .gitignore and .dockerignore, and commit a .env.example with 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 /config do laboratório responde só dbPasswordSet: true/false, nunca o valor.Do not return secrets in responses or logs. The lab's /config endpoint only answers dbPasswordSet: 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 o spring-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> with spring-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.
validate compile test package verify install deploy mvn package executa estas quatro, em ordemmvn package runs these four, in order ciclo de vida padrãodefault lifecycle hello-0.0.1.jar saída do packageoutput of package ~/.m2/repository cópia local no installlocal copy on install mvn clean apaga o target/ (ciclo à parte)deletes target/ (separate lifecycle)
Pedir uma fase executa todas as anteriores. Por isso mvn package também compila e testa. O clean é outro ciclo.Asking for a phase runs all the earlier ones. That is why mvn package also compiles and tests. clean is a separate lifecycle.
ComandoCommandO que fazWhat it does
mvn packageValida, compila, testa e gera target/hello-0.0.1.jar.Validates, compiles, tests and produces target/hello-0.0.1.jar.
mvn clean packageApaga o target/ antes. Use quando algo “não atualiza”.Deletes target/ first. Use it when something “does not update”.
mvn spring-boot:runCompila 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 packagePula os testes (use no Dockerfile; rode os testes no CI).Skips the tests (use in the Dockerfile; run the tests in CI).
mvn dependency:treeMostra quem trouxe cada biblioteca. Útil para conflitos de versão.Shows who brought in each library. Useful for version conflicts.
Use o Maven Wrapper (./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 propriedades app.* ao record Settings, já convertido para o tipo certo. É o jeito recomendado de ler configuração, em vez de espalhar @Value.@ConfigurationProperties(prefix = "app") binds the app.* group of properties to the Settings record, 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=true ele publica /actuator/health/liveness e /readiness, que testei: {"status":"UP"}. Só expus o health; expor tudo (*) revela detalhes internos.The actuator gives ready-made observability. With management.endpoint.health.probes.enabled=true it publishes /actuator/health/liveness and /readiness, which I tested: {"status":"UP"}. I exposed only health; 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]
  1. Variável de ambiente → propriedade, automático. O Spring converte o nome: troca _ por . e ignora maiúsculas, então APP_GREETING vira app.greeting e APP_DB_PASSWORD vira app.db-password. Medi: APP_DB_PASSWORD=x e APP_DBPASSWORD=x ligaram o campo dbPassword. 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, so APP_GREETING becomes app.greeting and APP_DB_PASSWORD becomes app.db-password. I measured: APP_DB_PASSWORD=x and APP_DBPASSWORD=x both set the dbPassword field. The reference describes the rule under “Binding From Environment Variables”.
  2. Placeholder: ${VARIAVEL:padrão}. Um valor do .properties pode citar outra propriedade ou variável. É o que a linha server.port=${PORT:8080} faz: usa PORT se existir, senão 8080. É assim que você faz o .properties “ler” uma variável com nome livre.Placeholder: ${VARIABLE:default}. A .properties value can cite another property or variable. That is what the line server.port=${PORT:8080} does: it uses PORT if it exists, otherwise 8080. This is how you make the .properties “read” a variable with a free name.
  3. O .env só entra se você mandar: a última linha, spring.config.import=optional:file:.env[.properties], diz ao Spring para ler o arquivo .env como se fosse um .properties. O optional: evita erro quando o arquivo não existe (no servidor, por exemplo), e o caminho é relativo a onde você roda o comando.The .env only comes in if you tell it to: the last line, spring.config.import=optional:file:.env[.properties], tells Spring to read the .env file as if it were a .properties. The optional: 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çãoSituationgreetingLiçãoLesson
nada definidonothing setvalor do application.propertiesvalue from application.propertieso 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-ambientedo-ambienteo ambiente sobrepõe o arquivothe environment overrides the file
ambiente + perfil devenvironment + dev profiledo-ambienteambiente vence até o arquivo do perfilenvironment beats even the profile file
--app.greeting=do-argumento + ambienteenvironmentdo-argumentoo argumento de linha de comando vence tudothe command-line argument beats everything
.env com app.greeting=do-dotenvdo-dotenvfunciona: o nome é o da propriedadeworks: the name is the property's
.env eand APP_GREETING=do-ambientedo-ambienteo ambiente vence o .envthe environment beats the .env
.env comwith APP_GREETING=x (estilo shell)(shell style)valor do application.propertiesvalue from application.propertiesnão surtiu efeito!had no effect!
o mesmo .env + ${APP_GREETING:…} no .propertiesthe same .env + ${APP_GREETING:…} in the .propertiesveio-do-dotenvo placeholder resolvethe placeholder resolves
A pegadinha do .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.
Acentos no .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 activateExemploExample
Variável de ambiente (a mais usada em Docker)Environment variable (the most used in Docker)SPRING_PROFILES_ACTIVE=prod
Argumento do jarJar argumentjava -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:

  1. python não existe, só python3. No Ubuntu o comando é python3, e o pip pode nem estar instalado (aqui no meu computador não está; ele vem no pacote python3-pip). Dentro de uma .venv ativada, python passa a existir.python does not exist, only python3. On Ubuntu the command is python3, and pip may not even be installed (on my computer here it is not; it comes in the python3-pip package). Inside an activated .venv, python starts to exist.
  2. pip install global dá erro. No Ubuntu 24.04, pip install requests respondeu error: externally-managed-environment. O Python do sistema é gerenciado pelo apt e o PEP 668 o protege de proposital. Não use --break-system-packages: crie um ambiente virtual.A global pip install errors. On Ubuntu 24.04, pip install requests answered error: externally-managed-environment. The system Python is managed by apt and PEP 668 protects it on purpose. Do not use --break-system-packages: create a virtual environment.
  3. O ambiente virtual (venv) precisa de um pacote a parte. python3 -m venv falhou com “ensurepip is not available… install the python3-venv package” até eu instalar python3-venv. Depois funcionou.The virtual environment (venv) needs a separate package. python3 -m venv failed with “ensurepip is not available… install the python3-venv package” until I installed python3-venv. After that it worked.
  4. 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.
pyproject.toml uv.lock no repositório (Git)in the repository (Git) uv sync uv run .venv/ python + pacotes só deste projetopython + packages for this project only apague e recrie quando quiserdelete and recreate whenever you want ~/.cache/uv cache de pacotespackage cache /usr/bin/python3 (sistema)/usr/bin/python3 (system) pip aqui falha: PEP 668pip here fails: PEP 668 ~/.local/share/uv/python Pythons baixados pelo uvPythons downloaded by uv
O manifesto e o lock ficam no Git. A .venv e o cache são descartáveis: nascem do manifesto e podem ser apagados sem medo.The manifest and the lock live in Git. The .venv and the cache are disposable: they are born from the manifest and can be deleted without fear.

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",
]
ComandoCommandO que faz (conferido)What it does (checked)
uv init --bareCria 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 syncFaz 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.12Baixa 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 cleanMostra (~/.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çãoSituationgreetingdb_password
nada definidonothing setolá do códigoolá do códigovazioempty
GREETING=ambienteambientevazioempty
só .env com GREETING, DB_PASSWORD e uma chave OUTRA.env only, with GREETING, DB_PASSWORD and a key OUTRAdo-dotenvsegredo
.env eand GREETING=ambienteambientesegredo
greeting=minusc (minúsculas no ambiente)(lowercase in the environment)minuscsegredo
  • O nome do campo (db_password) casa com a variável DB_PASSWORD sem diferenciar maiúsculas (o último caso da tabela). O ambiente vence o .env, e o .env vence o padrão da classe, a mesma escada do começo.The field name (db_password) matches the DB_PASSWORD variable case-insensitively (the last row of the table). The environment beats the .env, and the .env beats 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 .env que a classe não declara (como OUTRA) são ignoradas, em vez de dar erro.With extra="ignore" the .env keys the class does not declare (like OUTRA) 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:

src/*.ts seu códigoyour code node src/server.ts remove os tipos e roda; não confere tiposstrips types and runs; does not check types tsc --noEmit só confere os tipos; não gera nadaonly checks types; emits nothing tsc → dist/*.js confere e gera o JavaScriptchecks and emits the JavaScript
Rodar, conferir e compilar são tarefas diferentes. O Node roda .ts sem checar; quem checa é o tsc.Running, checking and compiling are different jobs. Node runs .ts without checking; tsc is what checks.
  • O Node roda .ts “apagando” os tipos, sem conferi-los. Testei um arquivo com const x: number = "texto": o Node executou normalmente, com o erro de tipo. Quem acusa o erro é o tsc --noEmit. Por isso o projeto tem o script typecheck, que deve rodar no CI e no editor.Node runs .ts by “erasing” the types, without checking them. I tested a file with const x: number = "texto": Node ran it normally, type error and all. What reports the error is tsc --noEmit. That is why the project has a typecheck script, which should run in CI and in the editor.
  • Só vale sintaxe “apagável”. Um enum precisa de código gerado, e o Node recusou com ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. A opção erasableSyntaxOnly do tsconfig.json existe para o tsc avisar disso no editor, antes de rodar.Only “erasable” syntax is valid. An enum needs generated code, and Node refused with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. The erasableSyntaxOnly option in tsconfig.json exists so tsc warns you in the editor, before running.
  • Em produção ainda se compila. O tsc gera o dist/*.js e você roda node 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. tsc emits dist/*.js and you run node 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"
  }
}
  • dependencies vão para a produção (aqui, o zod). devDependencies são só para desenvolver e compilar (typescript, @types/node). No Docker final, npm ci --omit=dev deixa as segundas de fora.dependencies go to production (here, zod). devDependencies are only for developing and compiling (typescript, @types/node). In the final Docker image, npm ci --omit=dev leaves the second group out.
  • "type": "module" faz o Node tratar os .js como módulos ES (import). Sem isso, ele espera require (CommonJS). É um dos erros mais comuns: “Cannot use import statement outside a module”."type": "module" makes Node treat .js files as ES modules (import). Without it, it expects require (CommonJS). It is one of the most common errors: “Cannot use import statement outside a module”.
  • package-lock.json fixa as versões exatas, como o uv.lock. npm install pode alterar o lock; npm ci apaga o node_modules e instala exatamente o lock, e falha se o lock e o package.json divergirem. Use npm ci no CI e no Docker.package-lock.json pins exact versions, like uv.lock. npm install may change the lock; npm ci deletes node_modules and installs exactly the lock, and fails if the lock and package.json disagree. Use npm ci in CI and Docker.
  • npx <ferramenta> roda um programa instalado no projeto (ou baixa um temporário). npm run <script> roda um item de scripts. A versão do Node pode ser fixada em engines; ferramentas como nvm e fnm trocam 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 from scripts. The Node version can be pinned in engines; tools like nvm and fnm switch 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çãoOptionPara que serveWhat it is for
module / moduleResolution: nodenextSegue 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).
rewriteRelativeImportExtensionsVocê 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, verbatimModuleSyntaxMantêm o código dentro do que o Node consegue rodar apagando tipos.Keep the code within what Node can run by erasing types.
strictLiga as conferências mais úteis (null, tipos implícitos). Deixe ligado.Turns on the most useful checks (null, implicit types). Leave it on.
outDir / rootDirDe 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 ito .env é ignorado: greeting = padrão do códigothe .env is ignored: greeting = code default
node --env-file=.env src/server.tslê 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 fileerro 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 filesegue 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.tso 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.

maven:3.9-eclipse-temurin-25 mvn package app.jar eclipse-temurin:25-jre só o necessário para rodaronly what is needed to run uv:python3.12-bookworm-slim uv sync --no-dev .venv python:3.12-slim-bookworm só o necessário para rodaronly what is needed to run node:24-slim npm ci + tsc dist/ node:24-slim (final) só o necessário para rodaronly what is needed to run docker run -e --env-file env_file: a config entra aquiconfig comes in here
A etapa de build tem as ferramentas pesadas. A etapa final leva só o artefato e o runtime, e recebe a configuração de fora, na hora de rodar.The build stage has the heavy tools. The final stage carries only the artifact and the runtime, and gets its configuration from outside, at run time.
MedidoMeasuredJavaPythonTypeScript
Tamanho da imagem finalFinal image size517 MB305 MB343 MB
Usuário do processoProcess user1000110001node
HealthcheckHealthcheckbash + /dev/tcp (sem curl)bash + /dev/tcp (no curl)Python + urllibPython + urllibNode + fetchNode + fetch
Porta no ComposePort in Compose808080003000

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 em localhost. Dentro do contêiner, 127.0.0.1 é só o próprio contêiner. O fastapi dev falhou por isso; o Spring, o fastapi run e o listen(…, "0.0.0.0") do Node funcionaram.Listen on 0.0.0.0, not localhost. Inside the container, 127.0.0.1 is only the container itself. fastapi dev failed because of that; Spring, fastapi run and Node's listen(…, "0.0.0.0") worked.
  • EXPOSE não publica porta. Quem abre a porta para o seu computador é o -p host:contêiner (ou ports: no Compose). O EXPOSE é só documentação.EXPOSE does not publish a port. What opens the port to your computer is -p host:container (or ports: in Compose). EXPOSE is only documentation.
  • docker run --env-file não é um parser de shell. Com GREETING="com aspas" o valor chegou ao app com as aspas ("com aspas"), e DB_PASSWORD=x # comentario chegou como x # comentario. O env_file do Compose, ao contrário, tirou as aspas e o comentário (conferi com docker compose config). Escreva o .env sem aspas e sem comentário na mesma linha, e ele serve aos dois.docker run --env-file is not a shell parser. With GREETING="com aspas" the value reached the app with the quotes ("com aspas"), and DB_PASSWORD=x # comentario arrived as x # comentario. Compose's env_file, by contrast, stripped the quotes and the comment (I checked with docker compose config). Write the .env with no quotes and no comment on the same line, and it works for both.
  • .dockerignore evita três desastres: copiar o seu .env (segredos na imagem), copiar a .venv ou o node_modules do seu computador (outro sistema, outro caminho) e invalidar o cache de camadas a cada mudança..dockerignore avoids three disasters: copying your .env (secrets in the image), copying your computer's .venv or node_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.lock ou package*.json primeiro 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 copy pom.xml, pyproject.toml/uv.lock or package*.json first 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 10001 ou USER node) e deixe segredos só nas variáveis em tempo de execução, nunca em ENV do Dockerfile nem em ARG, que ficam no histórico da imagem.Run as a regular user (USER 10001 or USER node) and keep secrets only in run-time variables, never in a Dockerfile ENV or ARG, 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 queWhatOndeWhereTamanho vistoSize seenComo limpar (e o que acontece)How to clean (and what happens)
Java: saída do buildJava: build outputtarget/22 MB./mvnw clean (o próximo build recria)(the next build recreates it)
Java: bibliotecasJava: libraries~/.m2/repository478 MBapagar 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 MBrm -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__/pequenosmallapagar; o Python recria. Já vai no .gitignore.delete; Python recreates it. Already in .gitignore.
Node: dependênciasNode: dependenciesnode_modules/42 MB (só 3 pacotesjust 3 packages)rm -rf node_modules eand npm ci
Node: compiladoNode: compiled outputdist/pequenosmallrm -rf dist eand npm run build
Node: cache global do npmNode: global npm cache~/.npm4,8 GBnpm cache clean --force
Dockerimagens, cache de buildimages, build cache14 GB recuperáveis na minha máquina14 GB reclaimable on my machinedocker 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

SintomaSymptomCausa provávelLikely causeSaídaWay out
javac: command not foundsó o JRE instaladoonly the JRE installedinstalar 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 conversionapp.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-1escape á, 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 useoutro processo ou um contêiner esquecidoanother process or a forgotten containerss -ltnp | grep 8080; docker ps; --server.port=…
externally-managed-environmentpip 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)
ModuleNotFoundErrorinterpretador 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 answerfastapi dev escuta em 127.0.0.1listens on 127.0.0.1fastapi 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 Nodefaltou 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 erroro Node não confere tiposNode does not check typesnpm run typecheck
Arquivos do projeto com dono rootProject files owned by rootcontêiner rodou como root com a pasta montadacontainer ran as root with the folder mounteddocker 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

TarefaTaskJava + Spring BootPython + FastAPITypeScript + Node
Ver a versãoCheck versionjava -versionpython3 --versionnode -v
Instalar dependênciasInstall dependencies./mvnw packageuv sync --frozennpm ci
Rodar em devRun in dev./mvnw spring-boot:runuv run fastapi dev app/main.pynpm 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/*.jaruv run fastapi run app/main.pynode dist/server.js
Variável de ambienteEnvironment variableAPP_DB_PASSWORD → app.db-passwordDB_PASSWORD → db_passwordprocess.env.DB_PASSWORD
Ler o .envRead the .envspring.config.import=optional:file:.env[.properties]env_file=".env" (pydantic-settings)--env-file-if-exists=.env
AmbientesEnvironmentsperfis: SPRING_PROFILES_ACTIVEprofiles: SPRING_PROFILES_ACTIVEum campo como APP_ENVa field like APP_ENVNODE_ENV
Limpar o projetoClean the project./mvnw cleanrm -rf .venvrm -rf node_modules dist
DocumentaçãoDocumentationdocs.spring.io/spring-bootfastapi.tiangolo.comnodejs.org/docs

Laboratório: reproduza em 15 minutosLab: reproduce in 15 minutes

  1. Crie as três pastas (java/, py/, ts/) com os arquivos desta nota e rode ./mvnw package, uv sync e npm 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 sync and npm ci. Note what each one created (target/, .venv/, node_modules/).
  2. 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 .env e 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 .env and that the password never shows up in the response.
  3. Reproduza as três armadilhas: o .env estilo shell no Spring, o fastapi dev dentro do Docker e o node src/server.ts sem --env-file.Reproduce the three traps: the shell-style .env in Spring, fastapi dev inside Docker and node src/server.ts without --env-file.
  4. Suba os três com docker compose up -d --build --wait, confira os três /config e encerre com docker compose down -v.Bring the three up with docker compose up -d --build --wait, check the three /config and finish with docker compose down -v.
  5. 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: delete target/, .venv/, node_modules/, dist/ and remove the three images. Rebuild one project from scratch to prove nothing was lost.
O que foi e o que não foi executado. Executei: o build e a execução dos três apps (o jar com o JDK 25 do computador; o Python e o uv em contêiner; o Node no computador e em contêiner), todas as tabelas de precedência, o 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 .env sozinha? O que cada uma usa para lê-lo?Why does none of the three languages read the .env by 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 o pom.xml não tem versão nas dependências do Spring?What does Maven do when you run mvn package? Why does the pom.xml have no version on the Spring dependencies?
  • O que é um perfil do Spring? Se application-prod.properties e uma variável de ambiente definem a mesma chave, qual vence?What is a Spring profile? If application-prod.properties and an environment variable define the same key, which wins?
  • Por que APP_GREETING=x no .env não funciona no Spring, mas funciona como variável de ambiente?Why does APP_GREETING=x in the .env not work in Spring, but work as an environment variable?
  • Para que serve a .venv? Por que pip install global falha no Ubuntu 24.04, e o que o uv muda?What is the .venv for? Why does a global pip install fail on Ubuntu 24.04, and what does uv change?
  • Qual a diferença entre fastapi dev e fastapi run, e por que isso importa em Docker?What is the difference between fastapi dev and fastapi run, and why does it matter in Docker?
  • O Node roda .ts sem conferir tipos. Quem confere? O que o erasableSyntaxOnly evita?Node runs .ts without checking types. What does check? What does erasableSyntaxOnly prevent?
  • Qual a diferença entre npm install e npm ci? Qual vai no Dockerfile e por quê?What is the difference between npm install and npm 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?