Skip to content

The Ampersand That Silently Truncated My Database URL

The connection string was correct. I checked it several times. The application still could not reach MySQL, and the error said nothing useful about why.

The URL was being cut in half before Java ever saw it, by the shell, at a character I had not thought of as a character.

The Refactor That Made It Possible

The inherited application had its database credentials written into application.properties. That is the thing to fix first, because everything else depends on it:

spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
server.port=${SERVER_PORT}

The point is not that hardcoded credentials are bad, which everyone already agrees with. It is that the same compiled JAR now reaches a different database depending only on the environment it starts in. No profile, no rebuild, no branch. That property is what makes one artifact deployable to a laptop, a container, and RDS, and it is the foundation the four-model deployment rests on.

Then the .env file, and the bug.

What Bash Does To a JDBC URL

# broken
SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/db?useSSL=false&serverTimezone=UTC

# correct
SPRING_DATASOURCE_URL="jdbc:mysql://localhost:3306/db?useSSL=false&serverTimezone=UTC"

Unquoted, & is not part of the string. It is the shell's background-process operator. Everything after it is parsed as a separate command, and the variable is assigned only what came before:

jdbc:mysql://localhost:3306/db?useSSL=false

Which is a valid JDBC URL. That is the whole problem. The application starts, the driver parses it happily, the connection attempt is made with a different timezone configuration than intended, and the failure surfaces somewhere entirely unrelated to the assignment that caused it.

Had the truncation produced something malformed it would have failed loudly at parse time. Instead it produced a shorter correct-looking URL, which is the difference between a bug you fix in a minute and one you spend an afternoon on.

This is invisible when you echo it back

echo $SPRING_DATASOURCE_URL prints the truncated value, and if you are checking against what you meant to write rather than reading it character by character, it looks fine. The missing part is at the end, past where the eye stops.

Any query string in any environment variable has this problem. JDBC URLs are just where most people meet it first, because they are the most common configuration value that contains &.

Two Local Paths, On Purpose

With configuration externalised, running the same JAR against two different databases is a matter of swapping four values. I set up both deliberately rather than picking one.

H2, in memory. No daemon, no container, no port. The schema is created at startup and gone at shutdown.

SPRING_DATASOURCE_URL=jdbc:h2:mem:ibtisamIQ
SPRING_DATASOURCE_USERNAME=sa
SPRING_DATASOURCE_PASSWORD=password

MySQL 8.4, native. The engine production actually runs.

The reason for two rather than one is that they answer different questions, and a single path answers only one of them.

H2 MySQL
Time to run seconds needs a running server
Answers does the application start and wire up does it work against the real engine
Catches broken beans, missing config, mapping errors dialect differences, real SQL behaviour
Misses anything engine-specific nothing, but slowly

H2 is the fast loop. If the application will not start, you find out immediately without a database being involved at all, which means a startup failure is unambiguously the application's fault.

MySQL is the honest loop. Hibernate's H2 dialect and its MySQL dialect generate different SQL, so passing on H2 is not evidence of passing on MySQL.

The Part That Undermines It Slightly

Switching between them is not purely an environment variable change, and I want to be accurate about that.

spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.sql.init.mode=embedded

The driver class and the Hibernate dialect have to change too, and those live in application.properties rather than in .env. So "the same artifact runs against both" is true of the JAR and requires editing a file inside it, which is not the clean story the abstraction promised.

The correct fix is Spring profiles: an application-h2.properties selected by SPRING_PROFILES_ACTIVE, which is itself an environment variable and preserves the property. I did it the manual way and it works, and the profile version is what I would build now.

Worth stating because the gap between "externalised configuration" and "everything is externalised" is where this kind of thing hides. Three properties stayed behind, and they are exactly the three that determine which database you are talking to.

The Same Variable, Four Times

Once the URL is an environment variable rather than a property, the interesting part is how little changes between environments.

Where it runs SPRING_DATASOURCE_URL points at
Laptop, H2 jdbc:h2:mem:ibtisamIQ
Laptop, MySQL jdbc:mysql://localhost:3306/...
Docker Compose jdbc:mysql://db:3306/...
Kubernetes jdbc:mysql://mysql-service:3306/...
EKS the RDS endpoint

Five destinations, one variable, no code change and no rebuild. The Compose and Kubernetes rows are the ones that make the point: db and mysql-service are DNS names that only exist inside their respective networks, and the application does not know or care that the host is a service name rather than an address.

That is what makes the artifact genuinely portable rather than nominally portable. A build that produces different bytes per environment is not one artifact, however the pipeline is arranged, and the tag on it becomes a claim you cannot check.

The port belongs in the same set

SERVER_PORT is externalised for the same reason and gets less attention. The ALB target group, the ECS task definition, the Kubernetes Service and the Dockerfile EXPOSE all have to agree with it, and hardcoding it in one place means five places have to be edited together. Making it a variable means one place decides and the rest read it.

Why This Belongs Before the Pipeline

All of this happened before any CI existed, and that ordering was the useful part.

Every problem here is faster to find locally. The truncated URL takes minutes on a laptop and much longer in a pipeline, where you cannot inspect the environment interactively and each attempt costs a full run. The dialect requirement is obvious the first time you switch by hand and mysterious the first time a test job fails on SQL syntax.

The rule I would state: validate the full lifecycle by hand before automating it. Not because automation is hard, but because automation removes the ability to look around, and every question you have not already answered becomes an expensive question.

By the time this reached CI, the only new variables were the pipeline's own.

There is a second reason, less obvious. A pipeline that was written against an application you have never run by hand encodes your assumptions rather than the application's requirements. Every mvn flag, every environment variable, every health check path is a guess until something has run it locally and confirmed it. Guesses that happen to work are indistinguishable from correct choices right up until one of them stops working, and then nobody knows which of them was load-bearing.

The Other Half of the Same Refactor

Externalising configuration was one of two changes that had to happen before this application could be deployed anywhere. The other was adding a health endpoint.

/actuator/health

Spring Boot Actuator provides it, and the inherited application did not include the dependency. Without it there is nothing for an ALB target group to poll, nothing for a Kubernetes readiness probe to check, and nothing for a Docker HEALTHCHECK to run.

The two changes pair because they answer the two questions every deployment target asks: how do I configure this and how do I know it is working. An application that cannot answer both is not deployable regardless of how good the pipeline is, and both answers have to exist in the application rather than in the infrastructure around it.

Adding the endpoint then produced its own problem, because Spring Security intercepted it and returned a redirect to the load balancer's health checker. That is a separate story and it is the same shape as this one: a change that looks complete, behaves correctly in the environment you tested it in, and fails the first time something other than a browser makes a request.

Source


Companion to: One Application, Four Ways