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

  1. What deploying means
  2. The artefact: JAR, WAR or image
  3. Build once, deploy in many places
  4. The twelve factors applied to CicloUrbana
  5. Hosting models
  6. Anatomy of a production environment
  7. Decisions to take before deploying
  8. Deployment strategies
  9. State, sessions and horizontal scaling
  10. Knowing whether a deployment went well, and rolling back
  11. Environments and parity
  12. Common Mistakes and Tips
  13. Exercises

  1. 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.

  1. 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:

  1. The same artefact runs identically on the laptop, in pre and in prod. There is no "it worked in pre-production".
  2. 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.
  3. The rollback is trivial and exact: ciclourbana:2.3.0 still 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.

  1. 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 pre and prod without 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.

  1. 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.

  1. 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 push and 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.

  1. 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.

  1. 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=75 leaves 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 of OOMKilled containers with no trace at all in the log.
  • ExitOnOutOfMemoryError makes an OutOfMemoryError kill 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.

server:
  forward-headers-strategy: framework   # interprets X-Forwarded-Proto / -Host / -Port

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:

maximum_replicas × maximum-pool-size  +  headroom (migrations, admin, monitoring)  <  max_connections
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-off

And 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.

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 40s

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:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]

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.

  1. 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:

  1. Backwards compatible schema. The migration that accompanies 2.4.0 must be applicable without breaking 2.3.0. Renaming capacity to total_docks in 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.
  2. Backwards compatible API. If Ribalta's mobile app is calling /api/v1/rentals on 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.

  1. 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.

  1. 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 5xx rate 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.

  1. 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 answers ERROR: column "capacity" does not exist. Hibernate propagates the exception, the GlobalExceptionHandler from 03-06 translates it and two thirds of the traffic receive 500.
  • 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:

-- V11__contract_drop_capacity.sql
ALTER TABLE stations DROP COLUMN capacity;

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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved