What is left is no longer building, but delivering. The Ribalta network works, it is observable, it is resilient and it is packaged, yet it still lives on development machines: nobody in the city can rent a bike yet. This lesson is the hinge of module 8. It contains no steps for any particular platform —those come in the next four— but rather the decisions you have to make before touching any console, because they are the same on Heroku, on AWS and on Kubernetes, and getting them wrong costs far more than picking the wrong provider.
We will look at exactly what changes when an application moves from your laptop to a server that serves real citizens, which artefact gets deployed and why CicloUrbana travels as an image, the twelve factors applied one by one to our project, the hosting models with their costs and trade-offs, the complete anatomy of a production environment, a reasoned checklist of everything that has to be decided —secrets, migrations, backups, JVM memory, time zone, HTTPS, connection pool, graceful shutdown— and the four deployment strategies with what each one demands of the application.
Contents
- What deploying means
- The artefact: JAR, WAR or image
- Build once, deploy in many places
- The twelve factors applied to CicloUrbana
- Hosting models
- Anatomy of a production environment
- Decisions to take before deploying
- Deployment strategies
- State, sessions and horizontal scaling
- Knowing whether a deployment went well, and rolling back
- Environments and parity
- Common Mistakes and Tips
- Exercises
- What deploying means
Running ./mvnw spring-boot:run on your laptop and deploying CicloUrbana in Ribalta are alike in exactly one thing: both start a JVM. Everything else changes.
| Aspect | In development | In production |
|---|---|---|
| Who starts the process | You, by hand | A supervisor, an orchestrator or a platform |
| What happens if the process dies | You see it and relaunch it | Nobody sees it: it must restart on its own |
| How many instances there are | One | Several, and changing |
| Where the database is | In a local, throwaway container | Managed, with real data that is unrecoverable if lost |
| Who can call the API | You | Anyone with an internet connection |
| How long the data lasts | Until the next docker compose down -v |
Years |
| What an error looks like | In the console | In a log aggregator, perhaps hours later |
| Cost of a failure | Losing five minutes | Citizens without bikes and a call from the council |
| Financial cost | Zero | Billed by the hour, traffic or no traffic |
Deploying is therefore making a specific version of the software available and operational for its real users, in a repeatable and reversible way. The three qualities in that definition are the ones that matter:
- Available and operational: it is not enough for the process to be alive; it has to serve requests correctly, with its database, its secrets and its configuration.
- Repeatable: if the deployment depends on somebody remembering a sequence of steps, it is not a deployment, it is a ceremony. The goal of the module is for the sequence to be written down and executed by a machine (08-05).
- Reversible: any deployment can go wrong. A deployment you cannot back out of within minutes is a gamble, not a delivery.
It is worth separating three terms that are used as synonyms and are not:
| Term | What it is |
|---|---|
| Build | Turning source code into an artefact: ./mvnw package, docker build |
| Release | Combining the artefact with the configuration of a specific environment and giving it an identifier |
| Deploy | Putting that release into execution, replacing the previous one |
This separation is not academic: it is the fifth of the twelve factors in section 4 and it explains why the artefact cannot carry the configuration of any environment inside it.
- The artefact: JAR, WAR or image
The first decision is what exactly gets copied to the server. There are three historical answers and only two are still reasonable.
| Executable JAR | WAR on an application server | Container image | |
|---|---|---|---|
| What it contains | Classes, dependencies and an embedded server (Tomcat) | Classes and dependencies, no server | A complete filesystem: JRE, application, time zone, certificates |
| How it runs | java -jar ciclourbana.jar |
Deployed to an external Tomcat/WildFly | docker run or the orchestrator |
| Who provides the JRE | The host machine | The host machine | The image itself |
| Typical size | 55-70 MB | 40-55 MB | 250-350 MB (with shared layers) |
| Isolation | None | Shares the JVM with other applications | Isolated process, network and filesystem |
| Reproducibility | Depends on the installed Java version | Depends on the server and its configuration | High: the environment travels inside |
| Rollback | Keep the previous JAR | Redeploy the previous WAR | Start the previous tag |
| Where it is deployed | VPS, PaaS, systemd | Legacy corporate servers | PaaS, ECS, Kubernetes, anywhere |
| Status in 2026 | Current and perfectly valid | Only out of organisational obligation | The industry standard |
Why the WAR is practically dead. It requires turning the application into a war (<packaging>war</packaging>, extending SpringBootServletInitializer, marking Tomcat as provided), and in exchange you inherit the whole operational burden of the application server: its Java version, its global configuration, its restarts that affect several applications at once, and the old source of errors that is libraries shared between deployments. The only legitimate reason to use it today is that the target is a corporate server that cannot be changed.
Why CicloUrbana is deployed as an image. The JAR is a dignified option —Heroku uses it in 08-02, and so does Elastic Beanstalk— but it leaves part of the environment out of the box: exactly which JRE version is on the machine, what time zone the system has, which root certificates it knows, which locale is configured. The image we built in 07-04 puts all of that inside, and with it achieves three things the module needs:
- The same artefact runs identically on the laptop, in
preand inprod. There is no "it worked in pre-production". - It is the unit the platforms of the coming chapters understand: ECS Fargate (08-03), Kubernetes (08-04) and the delivery pipelines (08-05) deploy images, not files.
- The rollback is trivial and exact:
ciclourbana:2.3.0still exists in the registry, byte for byte, and going back to it rebuilds nothing.
The practical rule: always build the JAR —it is the intermediate step— and publish the image as the deployment artefact.
- Build once, deploy in many places
This principle already appeared in 07-02, but here is where it becomes operational. Stated formally:
The artefact is built once only, from a specific commit, and that same binary copy travels through
preandprodwithout being recompiled. The only thing that changes between environments is the configuration injected from outside.
flowchart LR
C[commit 9f3a2b1] --> B[Single build<br/>ciclourbana:2.4.0]
B --> R[(Image registry)]
R --> P1[pre<br/>pre config]
R --> P2[prod<br/>prod config]
P1 -->|same binary| OK1[Tested here...]
P2 -->|same binary| OK2[...is what runs here]
Why it matters so much. If you recompile for production, what you deploy is a binary nobody has tested. It may differ because of a dependency that resolved to another version, because of a different Maven profile, because of a variable in the build environment, or simply because somebody made a commit between the two builds. The tests from module 6 ran against one artefact and a different one reaches Ribalta: the guarantee evaporates.
The direct consequence: the artefact cannot carry environment configuration inside it. No application-prod.yml with the real password packaged in the JAR, no Dockerfile with ENV SPRING_PROFILES_ACTIVE=prod, no Maven profiles that produce a "production" JAR different from the "pre" one. If the binary contains an environment decision, it stops being one binary and becomes several.
What can —and should— travel inside:
| Goes inside the artefact | Goes outside, in the environment |
|---|---|
| The code and the dependencies | Passwords, secrets, API keys |
The application-*.yml files without secrets (per-environment values) |
Which profile is activated (SPRING_PROFILES_ACTIVE) |
| The Flyway migrations | Database URL and credentials |
| Sensible default values | Pool sizes, memory limits, log level |
| The JRE and the time zone (in the image) | Addresses of external services |
That is why in 07-02 the profile files were versioned free of secrets, with ${JWT_SECRET} placeholders that have no default value: the file describes the shape of the configuration, the environment provides the dangerous content.
- The twelve factors applied to CicloUrbana
The Twelve-Factor App is a methodology published in 2011 by the Heroku team describing how an application should be built to be deployable, scalable and operable on modern platforms. Fifteen years later it is still the best pre-deployment checklist. Applied to our project:
| # | Factor | What it requires | CicloUrbana's situation |
|---|---|---|---|
| 1 | Codebase | One repository, many deployments | One Git repository; pre and prod are deployments of the same code at different commits |
| 2 | Dependencies | Declared explicitly, never implicit from the system | pom.xml with versions managed by the Spring Boot BOM; nothing installed by hand on the server |
| 3 | Config | In the environment, not in the code | SPRING_PROFILES_ACTIVE, JWT_SECRET, SPRING_DATASOURCE_* as variables (07-02) |
| 4 | Backing services | PostgreSQL, mail or payments are attachable resources, swappable by URL | Moving from local PostgreSQL to RDS means changing SPRING_DATASOURCE_URL, without touching code |
| 5 | Build, release, run | Three separate and strict stages | mvn verify → docker build+push → deployment of a specific tag |
| 6 | Processes | Stateless, nothing shared in memory or on local disk | There is no session: the JWT from 05-04 carries the identity; nothing is saved to disk |
| 7 | Port binding | The application exposes its service on a port, by itself | Embedded Tomcat on 8080, management on 8081 (07-01); there is no external server containing it |
| 8 | Concurrency | Scale by adding processes, not threads inside one giant process | Replicas of the image are added behind the load balancer |
| 9 | Disposability | Fast startup and graceful shutdown | SIGTERM → graceful shutdown from 01-05, with preStop and a generous grace period |
| 10 | Dev/prod parity | dev, pre and prod as similar as possible |
Testcontainers with PostgreSQL 16 in tests (06-05) and managed PostgreSQL 16 in production |
| 11 | Logs | An event stream to stdout, collected by the platform |
Logback writes to the console; no files rotated by the application (expanded in 09-05) |
| 12 | Admin processes | One-off tasks as ephemeral processes of the same code | Flyway migrations as a step prior to deployment, using the same image |
It is worth pausing on the three that break the most deployments:
Factor 3 (config). The acid test is simple: could you make the repository public right now without compromising anything? If the answer is no, the configuration is in the code.
Factor 6 (stateless processes). The day one instance keeps something in memory that another needs —a basket, a session, a write cache, a file uploaded to /tmp— horizontal scaling stops working and intermittent errors appear that are impossible to reproduce: they depend on which instance the request happened to land on.
Factor 11 (logs as a stream). The application must not open files, must not rotate them and must not know where its messages end up. It writes to stdout and it is the platform —Heroku, CloudWatch, the Kubernetes agent— that collects them. If the application writes to /var/log/ciclourbana.log inside a container, those logs disappear with the container.
- Hosting models
| Model | What you manage | Operational effort | Typical cost (month) | When to choose it |
|---|---|---|---|---|
| Own server / VPS | Operating system, Java, updates, security, TLS, backups, startup | Very high | 5-40 € | Minimal budget, a single instance, somebody who knows how to administer Linux |
| PaaS (Heroku, Render, Railway) | Only the code and the variables | Very low | 25-120 € | First deployment, small teams, little time (08-02) |
| Managed containers (ECS Fargate, Cloud Run, App Runner) | The image and its task definition | Low-medium | 60-250 € | Real production without wanting to operate an orchestrator (08-03) |
| Kubernetes (EKS, GKE, AKS) | Manifests, cluster versions, add-ons | High | 200 € + cluster | Many services, several teams, portability requirements (08-04) |
| Serverless functions (Lambda) | Only the code of each function | Low | Per invocation | Sporadic and very irregular workloads |
What a project like CicloUrbana would choose at each stage of its life:
- Prototype and demo for the council: a PaaS. One
git pushand there is a URL with HTTPS. The learning cost is close to zero and the focus stays on the product. - A real city service, a single team: managed containers. This is the sweet spot: serious infrastructure (private network, managed database, load balancer, autoscaling) without the burden of operating a cluster. It is the option 08-03 develops in detail.
- The network grows to several services and teams: Kubernetes, once the cost of coordinating deployments exceeds the cost of learning the orchestrator.
- Never, in our case: serverless functions. A Spring Boot application with a connection pool to PostgreSQL fits badly into a model of ephemeral processes; the JVM's cold start (mitigated, not eliminated, by SnapStart) and the multiplication of database connections are real problems.
And a warning that runs through the whole module: these services are billed, almost always by hour of active resource and not by traffic. A managed database and a load balancer cost the same with zero requests as with a thousand. When you finish a practical exercise in 08-02, 08-03 or 08-04, destroy the resources. At the end of each lesson there is an exact list of what to delete.
- Anatomy of a production environment
A serious deployment is never "a server with the application on it". It is a set of pieces with separate responsibilities:
flowchart TD
U[Citizens of Ribalta] --> DNS[DNS<br/>ciclourbana.ribalta.example]
DNS --> LB[Load balancer<br/>TLS termination · certificate]
LB --> A1[CicloUrbana replica 1]
LB --> A2[CicloUrbana replica 2]
LB --> A3[CicloUrbana replica 3]
A1 --> BD[(Managed PostgreSQL 16<br/>private network · backups)]
A2 --> BD
A3 --> BD
A1 -.reads at startup.-> S[Secrets store]
A2 -.-> S
A3 -.-> S
REG[(Image registry<br/>ciclourbana:2.4.0)] -.deploys.-> A1
A1 -.logs and metrics.-> O[Observability]
A2 -.-> O
A3 -.-> O
| Piece | Responsibility | What happens if it is missing |
|---|---|---|
| DNS | Translate the public name into the load balancer's address | Users would have to know an IP that also changes |
| Load balancer / TLS termination | Distribute traffic, check health, terminate HTTPS | Without it there are no multiple replicas and no managed certificate |
| Application instances | Run the image; they are cattle, not pets | A single instance means downtime on every deployment |
| Managed database | Persistence, backups, minor upgrades, high availability | Operating PostgreSQL by hand is the task that consumes the most time and turns out worst |
| Secrets store | Keep and rotate credentials outside the repository | Secrets end up in Git or in a chat |
| Image registry | Store every published, immutable version | There is no exact rollback |
| Observability | Aggregated logs, metrics and traces | A production failure is investigated blind (module 9) |
Two ideas worth fixing in your mind. First: instances are disposable. None has a name of its own, none stores anything valuable, any of them can die at any moment and be replaced. Everything that must be preserved lives in the database. Second: the database is never exposed to the internet. It lives on a private network and only accepts connections from the application instances. It is the security rule most often broken in improvised deployments.
- Decisions to take before deploying
This is the checklist you have to answer before choosing a platform. Each point has a correct answer with nuances, not a preference.
7.1 How are secrets managed?
CicloUrbana needs at least three: the PostgreSQL password, the HS256 signing secret for the JWT (05-04) and the payment gateway key. Options, from worst to best:
| Approach | Assessment |
|---|---|
In a versioned application-prod.yml |
Unacceptable. It stays in Git history forever |
In the image (ENV in the Dockerfile) |
Unacceptable. Anyone with access to the registry reads them with docker history |
| Environment variable set by hand on the platform | Acceptable as a minimum viable option; no rotation, no auditing |
| Secrets store (Secrets Manager, SSM, Vault, Sealed Secrets) | The right answer. Encrypted, audited, rotatable, with per-identity permissions |
And three invariable rules: never version credentials; rotate any secret that may have been exposed, even if the commit was deleted afterwards; and apply least privilege: the application's PostgreSQL user does not need to be a superuser nor to be able to create databases.
7.2 How are the Flyway migrations run?
There are two models and the choice changes with the number of instances.
| On application startup | As a step prior to deployment | |
|---|---|---|
| How | spring.flyway.enabled: true, applied during startup |
An ephemeral process (Job, one-off task, release phase) runs the migrations and then the instances are deployed |
| With one instance | Perfect | Correct, slightly more machinery |
| With several instances | Flyway takes a lock in the database: one migrates and the rest wait | The instances start with the schema already in place |
| If the migration fails | The application does not start, and may retry in a loop | The deployment stops before touching the live instances |
| Long migration (index over millions of rows) | Blocks startup and trips the health probes | Runs at its own pace, with no probes breathing down its neck |
| Visibility | Mixed into the startup log | A step of its own, with its result made explicit |
| Database permissions | The application always needs DDL permissions | The application can run with data-only permissions |
The course's recommendation: migrating at startup is perfectly valid as long as there is a single instance or the deployment is recreate; as soon as there are several replicas and zero-downtime deployments, the prior step is preferable. With ddl-auto: validate from 04-08, moreover, an instance that starts against a schema that does not match fails immediately and clearly, instead of corrupting data.
The important nuance with several instances: although Flyway serialises correctly by means of a lock, during a rolling update the old and the new version coexist against the same schema. That forces every migration to be backwards compatible —the expand/contract pattern from 04-08— and it is the point that links this list to section 8.
7.3 Backups and restores
A backup that has never been restored is not a backup, it is an intention. Three decisions:
- RPO (Recovery Point Objective): how much data you can afford to lose. With daily backups, up to 24 hours of rentals. With point-in-time recovery (PITR), seconds.
- RTO (Recovery Time Objective): how long you can take to come back. Restoring 20 GB from a snapshot takes tens of minutes.
- Restore drill: at least once, restore into a separate environment and check that the application starts against that copy. It is the only way to know it works.
Managed databases (Heroku Postgres, RDS) take automatic backups; you only have to verify the retention window and that deleting the instance does not take the backups with it.
7.4 Sizing JVM memory in a container
Already seen in 07-04, here as a deployment decision: the container has a memory limit and the JVM must respect it with headroom.
# Standard variable recognised by the JVM without touching the ENTRYPOINT
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"MaxRAMPercentage=75leaves the remaining 25 % for metaspace, thread stacks, direct network buffers and the process itself. Setting 100 % —or setting nothing and trusting the old heuristic's default 25 %— is the number one cause ofOOMKilledcontainers with no trace at all in the log.ExitOnOutOfMemoryErrormakes anOutOfMemoryErrorkill the process instead of leaving a half-alive JVM that responds badly. With the platform restarting automatically, that is what you want.- A sensible starting point for CicloUrbana: a 1 GB memory limit and 1 vCPU per replica.
7.5 Time zone and Locale
An unconfigured container runs in UTC and with the POSIX Locale. Real consequences: the reports scheduled for 3 in the morning (07-03) run at 5 Ribalta time in summer; formatted dates come out in the Anglo-Saxon format; and the amount of a rental is printed with a decimal point.
The correct decision is twofold: set TZ=Europe/Madrid in the image or the environment, and always store instants in UTC (TIMESTAMPTZ in PostgreSQL, Instant in Java), converting to the local zone only when presenting. The first avoids operational surprises; the second makes the system correct even if the zone changes.
7.6 HTTPS and proxy headers
In production, TLS is terminated by the load balancer and the application receives plain HTTP inside the private network. That is fine —it is the usual and efficient arrangement— but it introduces a problem: Tomcat believes the request arrived over http on port 8080, so the URLs it generates (redirects, the Location of a 201 Created, Swagger links) come out wrong.
With this property, Spring Boot registers a filter that reconstructs the original scheme, host and port from the X-Forwarded-* headers the load balancer adds. Without it, a correct POST /api/v1/rentals returns Location: http://10.0.2.31:8080/api/v1/rentals/1042 instead of https://ciclourbana.ribalta.example/api/v1/rentals/1042.
Security warning: forward-headers-strategy must be enabled only when there really is a trusted proxy in front. If the application is directly reachable, a client can forge X-Forwarded-For and pollute the logs or any decisions based on IP. On Kubernetes and on ECS, where the Service or the security group guarantees that only the load balancer reaches the application, it is safe.
7.7 The HikariCP pool versus max_connections
This is the most frequent sizing mistake when scaling. HikariCP opens 10 connections per instance by default (04-02). With 4 replicas that is 40 permanent connections; if you also deploy with a rolling update, for a few seconds there are 5 replicas and 50. And the migrations, the one-off tasks and any administration tool add more.
A small managed PostgreSQL instance usually comes with max_connections between 80 and 120, and the engine itself reserves a few for the superuser. The rule:
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 3000 # fail fast if no connection is free
max-lifetime: 1200000 # 20 min, below the load balancer's and the engine's cut-offAnd a counter-intuitive principle worth internalising: a large pool does not give more throughput. PostgreSQL serves each connection with a process; past the point where the cores and the disk saturate, more connections only add contention. A pool of 10 per instance is almost always faster than one of 50.
7.8 Graceful shutdown and preStop
When the platform decides to retire an instance it sends it SIGTERM. With the graceful shutdown from 01-05 configured, Spring Boot stops accepting new requests, finishes those in flight and closes the executors and the pool.
One subtle piece is missing: the load balancer takes a few seconds to find out that this instance should no longer receive traffic. If the process stops accepting connections at the very instant it receives the SIGTERM, the requests the load balancer keeps sending during that window fail with 502. The universal solution is to sleep for a few seconds before starting to shut down, which in Kubernetes is expressed with a preStop (08-04) and on other platforms with an equivalent margin:
During those 10 seconds, the instance keeps serving normally while the load balancer takes it out of rotation. Then the graceful shutdown begins. The total grace period must be greater than preStop + timeout-per-shutdown-phase, or the platform will kill the process halfway through.
- Deployment strategies
The question is how to go from version N to N+1 without leaving Ribalta without bikes.
flowchart TD
subgraph Recreate
R1[v1 v1 v1] --> R2[--- downtime ---] --> R3[v2 v2 v2]
end
subgraph Rolling
O1[v1 v1 v1] --> O2[v2 v1 v1] --> O3[v2 v2 v1] --> O4[v2 v2 v2]
end
subgraph Blue-Green
B1[blue v1 live · green v2 under test] --> B2[switch traffic to green]
end
subgraph Canary
C1[95% to v1 · 5% to v2] --> C2[50/50 if the metrics hold up] --> C3[100% to v2]
end
| Strategy | Service downtime | Resource cost | Complexity | Rollback | What it demands of the application |
|---|---|---|---|---|---|
| Recreate | Yes, seconds or minutes | None extra | Minimal | Redeploy the previous version | Nothing special: versions never coexist |
| Rolling update | No | +1 temporary instance | Low (native in ECS and Kubernetes) | rollout undo or redeploy the previous tag |
Coexistence of N and N+1: backwards compatible schema and API |
| Blue-green | No | Double during the transition | Medium | Switch back: seconds | Coexistence during the window; two complete environments |
| Canary | No | +1 instance | High: requires percentage routing and per-version metrics | Withdraw the canary | Prolonged coexistence and metrics separated by version |
What version coexistence really demands. During a rolling update of CicloUrbana there are, say, two replicas on version 2.4.0 and one on 2.3.0 talking to the same database. That forces two kinds of compatibility:
- Backwards compatible schema. The migration that accompanies 2.4.0 must be applicable without breaking 2.3.0. Renaming
capacitytototal_docksin a single step kills the old version the moment it is applied. The expand/contract pattern from 04-08 solves it in three deployments: first add the new column and write to both (expand), then deploy the code that only uses the new one, and finally drop the old one (contract), each step compatible with the previous one. - Backwards compatible API. If Ribalta's mobile app is calling
/api/v1/rentalson all three replicas, it cannot receive responses with different shapes. Adding fields is safe; removing them or changing their type is not.
The choice for CicloUrbana: rolling update as the norm —it is what ECS and Kubernetes do by default and it is enough to respect backwards compatibility—, recreate only for destructive migrations that require stopping (and with notice to citizens), and blue-green if one day the council demands being able to validate the new version with real traffic before switching.
- State, sessions and horizontal scaling
With several replicas behind a load balancer, the same person can land on replica 1 for one request and on replica 3 for the next. Anything stored in the memory of one instance stops existing for the others.
The classic solution —sticky sessions— consists of the load balancer tying each user to one instance. It works, but it is a poor solution: when that instance is retired during a deployment, all its users lose their session; and load distribution becomes unbalanced. The second classic solution is a shared session in Redis (Spring Session), valid and widely used, but it adds one more piece of infrastructure.
CicloUrbana needs neither of the two, and the reason is in 05-04: authentication is by JWT. The token is kept by the client, travels with every request and contains the signed identity and roles. The server remembers nothing between requests: it validates the signature and decides. Any replica can serve any request from any user, and a replica that dies takes no session with it. That is factor 6 genuinely satisfied, and it is why the horizontal scaling of the coming chapters is simply "add more copies".
One exception remains that is worth watching: local caches. When Spring Cache appears in 09-02, an in-memory cache per instance means each replica may hold a different value and that invalidating on one does not invalidate on the others. It is acceptable for data that tolerates being a few seconds out of date, and it calls for a distributed cache when it does not.
- Knowing whether a deployment went well, and rolling back
A deployment does not end when the platform says "complete". It ends when there is evidence that the new version behaves at least as well as the previous one. What to look at, in the first ten minutes:
| Signal | What it indicates | Where it comes from |
|---|---|---|
All replicas returning 200 on readiness |
The context started and the database responds | /actuator/health/readiness (07-01) |
| No container restarts | There is no OOMKilled and no liveness failure |
The platform |
Stable 5xx error rate |
The new version does not break cases that worked | Metrics (09-03) |
| Stable p95 and p99 latency | There is no new unindexed query and no saturated pool | Metrics |
| No new exceptions in the log | Failures that do not reach 5xx but break functionality |
Aggregated logs (09-05) |
/actuator/info with the expected SHA |
What is deployed is what you think is deployed | build-info and Git data (07-01) |
| A real smoke test | A fictional user authenticates and queries stations | The pipeline (08-05) |
That last row of the table —build-info with the commit SHA— looked like a minor detail in 07-01 and here it shows its value: it is what turns "I think it deployed" into a verifiable fact.
And the rollback. The rule is to decide before deploying how you go back and how long it takes:
- Application: redeploy the previous image tag. It is fast (the image is in the registry) and exact. Seconds or a few minutes.
- Database: it is not rolled back. An applied migration stays applied; undoing it with another migration going the other way is dangerous and sometimes impossible without losing data. That is why the schema must be backwards compatible: so that the application can be rolled back without touching the database.
- Decision criterion: fixed in advance. "If the
5xxrate exceeds 2 % for five minutes, we roll back", and you roll back before investigating the cause. Investigating with production broken is the worst possible combination.
- Environments and parity
| Environment | What for | Data | Who has access | Acceptable differences from prod |
|---|---|---|---|---|
| dev | Day-to-day work | Fictional, disposable | The developer | Many: Swagger open, DEBUG logs, local database |
| test | Running the suite (module 6) | Generated by the test | The pipeline | Ephemeral containers with Testcontainers |
| pre | Validating the candidate version | Anonymised copy or realistic volume | Team and council | As few as possible: same kind of infrastructure, fewer replicas |
| prod | The real service | Real | The citizens | — |
The tenth factor asks you to reduce the distance between environments in three dimensions: time (what is written today gets deployed today, not in a month), people (whoever writes the code takes part in deploying it) and tools (the engine, the version and the configuration are the same).
The third is where people sin most, and where this course has already made the right decisions: PostgreSQL 16 everywhere —including in the tests, thanks to Testcontainers (06-05), instead of H2—, ddl-auto: validate with Flyway in every environment with a persistent database (04-08), and the same image travelling through pre and prod.
What can legitimately differ between pre and prod: the number of replicas, the size of the database instance, the volume of data, the external services (simulated in pre, real in prod) and the log level. None of that changes the binary.
Common Mistakes and Tips
Recompiling for each environment. A Maven profile that produces a "production" JAR different from the one that was tested breaks the guarantee of all of module 6. Build once; configure many times.
Putting secrets in the image. ENV JWT_SECRET=... in the Dockerfile is written into a layer and is read with docker history without even running the container. Secrets go in at runtime.
Exposing the database to the internet "so I can connect with DBeaver". It is the most common entry point for a breach. You get in through a tunnel, a bastion or a managed session, and always with your own credentials, read-only where read-only will do.
Deploying with no rollback plan. "If it goes wrong we'll see" means investigating under pressure with the service down. Decide the rollback criterion before you press the button.
Confusing liveness with readiness. If the liveness probe includes the database, a momentary outage of the engine restarts all the replicas in a cascade and turns a two-minute incident into a twenty-minute one. Liveness = is the process unrecoverable? Readiness = can it serve right now? (07-01).
Ignoring the pool versus max_connections. The system works with two replicas and fails when scaling to six with FATAL: sorry, too many clients already. Calculate the maximum before scaling, not after.
Deploying on a Friday afternoon. It is not superstition: it is that the cost of a failure depends on how many people are available to fix it. When the pipeline and the rollback are solid (08-05), it stops mattering; until then, it matters.
Tip: write the runbook before the first deployment. A short document answering: how you deploy, how you roll back, where the logs are, how you restore the database and who you call. Half an hour of writing that pays for itself on the first bad day.
Tip: always destroy the practice resources. Everything you create in the lessons that follow is billed by the hour. A managed database forgotten for a month is a real and avoidable bill.
Exercises
Exercise 1
Audit CicloUrbana against the twelve factors as it stands at the end of module 7. For each factor, state whether it is satisfied, partially satisfied or not satisfied, with a sentence of justification and, where it is not satisfied, the concrete correction. Pay particular attention to factors 3, 5, 6 and 12.
Exercise 2
Ribalta council puts forward three scenarios and asks, for each one, for a hosting model, a deployment strategy and a model for running the migrations, with justification:
- (a) A demo for the full council meeting in two weeks' time, one developer, an almost nonexistent budget, no real data.
- (b) A production service for 40,000 citizens, a team of four, with no infrastructure specialist, and a requirement not to interrupt the service during daytime hours.
- (c) The network grows to five applications (bikes, scooters, parking, incidents, citizen portal) with three teams and a requirement to be able to change cloud provider.
Exercise 3
CicloUrbana has been deployed with three replicas behind a load balancer. Version 2.5.0 includes a migration that renames the capacity column of stations to total_docks, along with the corresponding code. A rolling update is launched with the migrations running as each instance starts. Describe minute by minute what happens, what citizens see, why rolling the application back does not fix the problem, and rewrite the complete plan so that the same functional change is delivered with no service downtime.
Solutions
Solution 1
| # | Factor | Status | Justification and correction |
|---|---|---|---|
| 1 | Codebase | Satisfied | One Git repository, several deployments of the same code |
| 2 | Dependencies | Satisfied | pom.xml with the Spring Boot BOM; the JRE travels in the image since 07-04 |
| 3 | Config | Partial | 07-02 moved the secrets out into variables, but they are set by hand. Correction: a secrets store with rotation (08-03) |
| 4 | Backing services | Satisfied | PostgreSQL and the gateway are configured by URL; changing instance touches no code |
| 5 | Build, release, run | Not satisfied | Today there is no real separation: you build by hand and run by hand. Correction: the pipeline from 08-05 |
| 6 | Processes | Satisfied | No server session thanks to the JWT; nothing on local disk |
| 7 | Port binding | Satisfied | Embedded Tomcat, 8080 and 8081 |
| 8 | Concurrency | Partial | The application supports it, but it has never been run with more than one instance. Correction: verify in pre with two replicas, reviewing ShedLock and the pool |
| 9 | Disposability | Satisfied | shutdown: graceful since 01-05 and stop_grace_period in Compose (07-04). The preStop-style margin is still pending |
| 10 | Dev/prod parity | Satisfied | PostgreSQL 16 in tests with Testcontainers and in production; Flyway in both |
| 11 | Logs | Partial | It writes to stdout, but nobody aggregates them. Correction: an aggregator (09-05) |
| 12 | Admin processes | Not satisfied | Migrations are applied at startup and there is no standard way to launch a one-off task. Correction: a prior deployment step using the same image |
Conclusion of the audit: the application is well built; what is missing is the process around it (factors 5 and 12), which is precisely the subject of this module.
Solution 2
(a) Demo for the council meeting. Hosting: PaaS (08-02). In one afternoon there is a URL with HTTPS and a managed database, without learning anything about networking or IAM. Strategy: recreate; with a single instance and no real users, thirty seconds of downtime does not matter. Migrations: at startup, for simplicity, or in the release phase if the PaaS offers one. Critical detail: destroy the application after the meeting, because the free tier no longer exists.
(b) Production for 40,000 citizens. Hosting: managed containers (ECS Fargate + RDS, 08-03). Reason: you need a private network, a database with backups and high availability, a load balancer with TLS and autoscaling, but the team has nobody who can devote themselves to operating Kubernetes; Fargate removes server management. Strategy: rolling update with a minimum of two replicas and minimumHealthyPercent: 100, backed by the health check on /actuator/health/readiness. Migrations: prior step, as a one-off task using the same image, plus expand/contract discipline; with replicas coexisting, it is the only sensible option.
(c) Five applications and three teams. Hosting: Kubernetes (08-04). Here it does pay off: the complexity is amortised across five services, each team deploys its Deployment without coordinating with the others, and the manifests are portable between providers, which was the explicit requirement. Strategy: rolling update by default and canary for the citizen-facing services, with per-version metrics. Migrations: a Kubernetes Job prior to the deployment, one per service, each owning its own schema. An honest warning: the database is still managed outside the cluster; running PostgreSQL inside Kubernetes multiplies the risk with no clear gain.
Solution 3
Minute by minute.
- Minute 0. The orchestrator starts an instance with 2.5.0. On startup, Flyway applies
ALTER TABLE stations RENAME COLUMN capacity TO total_docks. The migration takes milliseconds and takes effect immediately for everyone. - Minute 0 + 1 second. The two 2.4.0 replicas are still alive and still running
SELECT ... capacity ... FROM stations. PostgreSQL answersERROR: column "capacity" does not exist. Hibernate propagates the exception, theGlobalExceptionHandlerfrom 03-06 translates it and two thirds of the traffic receive500. - Minute 1. The new instance passes its readiness check and starts receiving traffic: one third of the requests work. The symptom the citizen sees is the worst possible one: the application fails intermittently, and reloading "sometimes fixes" the problem.
- Minutes 1-4. The rolling update replaces the other two replicas. When it finishes, everything works again. A few minutes of partial service have been lost.
- If somebody rolls back during the window, the disaster is greater: all three replicas go back to 2.4.0, which looks for
capacity, and the column no longer exists. The service goes from failing for two thirds to failing for 100 %.
Why rolling back fixes nothing. The rollback restores the code, but the migration has already been applied and the schema does not come back on its own. It is the central lesson of section 8: the database is not rolled back, so the application can only be rolled back if the schema is compatible with the previous version. Here it is not, and the only way back would be another migration renaming the column again, written and applied under pressure.
The correct plan, in three deployments (expand/contract, 04-08).
Deployment 1 — expand. A migration that adds the column without removing anything:
-- V10__expand_total_docks.sql
ALTER TABLE stations ADD COLUMN total_docks INTEGER;
UPDATE stations SET total_docks = capacity;
ALTER TABLE stations ALTER COLUMN total_docks SET NOT NULL;Code 2.5.0 reads capacity and writes to both columns (or a trigger keeps the copy in sync). Compatible with 2.4.0, which carries on using capacity as normal. The rolling update is safe: both versions work against the same schema.
Deployment 2 — switching the read. No migration. Version 2.6.0 reads and writes only total_docks. It is still backwards compatible, because capacity continues to exist and to be kept up to date, so rolling back to 2.5.0 works. This is the deployment to let settle for a few days: it is the practical point of no return.
Deployment 3 — contract. Only once no 2.5.0 instance is left alive and there is confidence in 2.6.0:
Reinforcements to the plan. Run the migrations as a prior step rather than at startup, so that the outcome of each one is explicit and not buried in the startup log; keep ddl-auto: validate, which makes an instance whose schema does not match fail immediately instead of leaving it failing query by query; and add a simple, verifiable rule to code review: no migration may contain a DROP COLUMN, a RENAME or an incompatible type change in the same deployment as the code that uses it.
Conclusion
CicloUrbana is not deployed yet, but you already know exactly what deploying it means. You distinguish build, release and deploy, and you are clear about the operational definition: available, repeatable and reversible. You know the three possible artefacts and why ours is a container image —the JAR is still valid, the WAR only out of obligation—, and you have internalised the principle that governs the whole module: build once, deploy in many places, with the inevitable consequence that the binary cannot carry the configuration of any environment inside it.
You have the twelve factors applied one by one to our project, with an honest diagnosis of where we stand: the application satisfies nearly all of them, and what fails is the process around it. You know which hosting models exist, how much they cost, how much operational effort they demand and which one corresponds to each stage in the life of the Ribalta network. And you have in your head the complete anatomy of a production environment: DNS, load balancer with TLS termination, disposable replicas, managed database on a private network, secrets store, image registry and observability.
Above all, you have the list of decisions to take before touching any console: how secrets are stored and rotated, whether Flyway migrates at startup or in a prior step —and why that changes radically with several instances—, what backups exist and whether any of them have ever been restored, how JVM memory is sized with MaxRAMPercentage so as not to end up OOMKilled, why you have to set TZ and store instants in UTC, how server.forward-headers-strategy fixes the URLs behind a load balancer that terminates TLS, how the HikariCP pool is calculated against PostgreSQL's max_connections, and why a preStop-style margin is needed before the graceful shutdown. You know the four deployment strategies and, most importantly, what each one demands of the application: version coexistence forces backwards compatibility in the schema —expand/contract— and in the API. And you know why CicloUrbana scales horizontally without sticky sessions or Redis: because the JWT from 05-04 left it stateless.
With that foundation, the rest of the module is concrete executions of the same ideas. The next lesson, Deploying to Heroku, makes the first real deployment: a PaaS that takes care of the operating system, the server, the certificate and the database, and where in under an hour https://ciclourbana-ribalta will be responding with its stations. We will look at its concepts —dynos, buildpacks, Procfile, config vars, release phase—, the awkward detail of a DATABASE_URL that is not a valid JDBC URL, how profiles and secrets fit in there, and also where the limits are that sooner or later force you out of a PaaS.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
