Local Development with Fiber v3 and Testcontainers: A Comprehensive Guide
In the world of software development, local development environments can often be a source of frustration due to their reliance on external services like databases or message queues. This reliance can lead to fragile scripts and inconsistent setups. However, with the introduction of Fiber v3 and the integration with Testcontainers, developers can now manage these service dependencies as an integral part of the application’s lifecycle. This makes the process more manageable, reproducible, and developer-friendly.
Introducing Services in Fiber v3
The upcoming v3 release of Fiber brings with it a powerful new feature known as Services. This feature provides a standardized method for starting and managing auxiliary services such as databases, message queues, and cloud service emulators. With Services, developers can seamlessly manage these backing services as part of the application’s lifecycle, eliminating the need for additional orchestration. Moreover, the newly introduced contrib module connects Services with Testcontainers, allowing for the easy and clean setup of real service dependencies in a testable manner.
In this post, we will explore how to utilize these new features by building a small Fiber application that uses a PostgreSQL container for data persistence, all managed through the new Service interface.
Key Highlights
- Utilize Fiber v3’s Services API to manage backing containers efficiently.
- Integrate with testcontainers-go to automatically start a PostgreSQL container.
- Implement hot-reloading using air for a fast local development cycle.
- Enable container reuse during development by disabling Ryuk and consistently naming them.
For a complete example, you can check out the GitHub Repository.
Local Development: The Current Landscape
Although this post focuses on Go development, it’s beneficial to look at how other major frameworks approach local development, even across different programming languages. In the Java ecosystem, key frameworks like Spring Boot, Micronaut, and Quarkus leverage the concept of development-time services. These services provide external dependencies required to run the application during development, ensuring they are disabled when the application is deployed.
Spring Boot, for instance, provides development-time services that supply external dependencies necessary during the development phase. Micronaut uses the concept of Test Resources to manage external resources required during development or testing. Similarly, Quarkus supports the automatic provisioning of unconfigured services in development and test mode, known as Dev Services.
Returning to Go, one of the most popular frameworks, Fiber, has introduced Services, including a new contrib module to support Testcontainers-backed services.
New Features in Fiber v3
Fiber v3 comes with several new features, but two stand out in particular for this discussion:
- Services: This feature allows developers to define and attach external resources, such as databases, to their apps in a composable manner. It ensures that external services are automatically started and stopped with your Fiber app.
- Contrib Module for Testcontainers: This module facilitates the starting of real backing services using Docker containers, managed directly from your app’s lifecycle in a programmable way.
Building a Simple Fiber App with Testcontainers
In this section, we will develop a simple Fiber application that utilizes a PostgreSQL container for data persistence. This application is based on the todo-app-with-auth-form Fiber recipe, but with the new Services API to start a PostgreSQL container instead of an in-memory SQLite database.
Project Structure
.
├── app
| ├── dal
| | ├── todo.dal.go
| | ├── todo.dal_test.go
| | ├── user.dal.go
| | └── user.dal_test.go
| ├── routes
| | ├── auth.routes.go
| | └── todo.routes.go
| ├── services
| | ├── auth.service.go
| | └── todo.service.go
| └── types
| ├── auth.types.go
| ├── todo.types.go
| └── types.go
├── config
| ├── database
| | └── database.go
| ├── config.go
| ├── config_dev.go
| ├── env.go
| └── types.go
├── utils
| ├── jwt
| | └── jwt.go
| ├── middleware
| | └── authentication.go
| └── password
| └── password.go
├── .air.conf
├── .env
├── main.go
└── go.mod
└── go.sum
This application exposes several endpoints for /users and /todos, and it stores data in a PostgreSQL instance initiated using Testcontainers. We will focus on setting up the PostgreSQL container using Testcontainers and managing its lifecycle with the Services API. Furthermore, we will explore how to use air for a quick local development experience and how to manage the graceful shutdown of the application, separating configurations for production and local development environments.
Step-by-Step Implementation
Step 1: Adding Dependencies
To get started, ensure that the necessary dependencies are added to your go.mod file. Note that Fiber v3 is still in development, so you’ll need to pull the main branch from GitHub:
go get github.com/gofiber/fiber/v3@main
go get github.com/gofiber/contrib/testcontainers
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get gorm.io/driver/postgres
Step 2: Defining a PostgreSQL Service with Testcontainers
To utilize the new Services API, we need to define a new service. We can implement the interface exposed by the Fiber app or use the Testcontainers contrib module to create a new service.
In config/config_dev.go, define a new function to add a PostgreSQL container as a service to the Fiber application using the Testcontainers contrib module. This file uses the dev build tag, so it will only be used when the application is started with air.
//go:build dev
package config
import (
"fmt"
"github.com/gofiber/contrib/testcontainers"
"github.com/gofiber/fiber/v3"
tc "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
// setupPostgres adds a Postgres service to the app, including custom configuration to allow
// reusing the same container while developing locally.
func setupPostgres(cfg *fiber.Config) (*testcontainers.ContainerService[*postgres.PostgresContainer], error) {
// Add the Postgres service to the app, including custom configuration.
srv, err := testcontainers.AddService(cfg, testcontainers.NewModuleConfig(
"postgres-db",
"postgres:16",
postgres.Run,
postgres.BasicWaitStrategies(),
postgres.WithDatabase("todos"),
postgres.WithUsername("postgres"),
postgres.WithPassword("postgres"),
tc.WithReuseByName("postgres-db-todos"),
))
if err != nil {
return nil, fmt.Errorf("add postgres service: %w", err)
}
return srv, nil
}
This process creates a reusable Service that Fiber will automatically start and stop along with the app. It’s registered as part of the fiber.Config struct that our application uses. This new service uses the postgres module from the testcontainers package to create the container. For more details, refer to the Testcontainers PostgreSQL module documentation.
Step 3: Initializing the Fiber App with the PostgreSQL Service
In the config/config.go file, the Fiber app is initialized using the ConfigureApp function for production environments. For local development, the fiber.App is initialized in config/config_dev.go using a similar function but with the contrib module to add the PostgreSQL service to the app config.
Define a context provider for services startup and shutdown and add the PostgreSQL service to the app config, including custom configurations. The context provider helps define a cancel policy for services startup and shutdown.
// ConfigureApp configures the fiber app, including the database connection string.
// The connection string is retrieved from the PostgreSQL service.
func ConfigureApp(cfg fiber.Config) (*AppConfig, error) {
// Define a context provider for the services startup.
// The timeout is applied when the context is actually used during startup.
startupCtx, startupCancel := context.WithCancel(context.Background())
var startupTimeoutCancel context.CancelFunc
cfg.ServicesStartupContextProvider = func() context.Context {
// Cancel any previous timeout context
if startupTimeoutCancel != nil {
startupTimeoutCancel()
}
// Create a new timeout context
ctx, cancel := context.WithTimeout(startupCtx, 10*time.Second)
startupTimeoutCancel = cancel
return ctx
}
// Define a context provider for the services shutdown.
// The timeout is applied when the context is actually used during shutdown.
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
var shutdownTimeoutCancel context.CancelFunc
cfg.ServicesShutdownContextProvider = func() context.Context {
// Cancel any previous timeout context
if shutdownTimeoutCancel != nil {
shutdownTimeoutCancel()
}
// Create a new timeout context
ctx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
shutdownTimeoutCancel = cancel
return ctx
}
// Add the Postgres service to the app, including custom configuration.
srv, err := setupPostgres(&cfg)
if err != nil {
if startupTimeoutCancel != nil {
startupTimeoutCancel()
}
if shutdownTimeoutCancel != nil {
shutdownTimeoutCancel()
}
startupCancel()
shutdownCancel()
return nil, fmt.Errorf("add postgres service: %w", err)
}
app := fiber.New(cfg)
// Retrieve the Postgres service from the app, using the service key.
postgresSrv := fiber.MustGetService[*testcontainers.ContainerService[*postgres.PostgresContainer]](app.State(), srv.Key())
connString, err := postgresSrv.Container().ConnectionString(context.Background())
if err != nil {
if startupTimeoutCancel != nil {
startupTimeoutCancel()
}
if shutdownTimeoutCancel != nil {
shutdownTimeoutCancel()
}
startupCancel()
shutdownCancel()
return nil, fmt.Errorf("get postgres connection string: %w", err)
}
// Override the default database connection string with the one from the Testcontainers service.
DB = connString
return &AppConfig{
App: app,
StartupCancel: func() {
if startupTimeoutCancel != nil {
startupTimeoutCancel()
}
startupCancel()
},
ShutdownCancel: func() {
if shutdownTimeoutCancel != nil {
shutdownTimeoutCancel()
}
shutdownCancel()
},
}, nil
}
This function:
- Defines a context provider for services startup and shutdown, applying a timeout for each.
- Adds the PostgreSQL service to the app config.
- Retrieves the PostgreSQL service from the app’s state cache.
- Uses the PostgreSQL service to get the connection string.
- Overrides the default database connection string with the one from the Testcontainers service.
- Returns the app config.
This setup ensures the fiber.App is initialized with the PostgreSQL service, which automatically starts and stops with the app. The service representing the PostgreSQL container is available as part of the application state, which can be easily retrieved from the app’s state cache. For more details, refer to the State Management docs.
Step 4: Optimizing Local Development with Container Reuse
In the config/config_dev.go file, the tc.WithReuseByName option is used to reuse the same container while developing locally, avoiding the wait for the database to be ready when starting the application. Set TESTCONTAINERS_RYUK_DISABLED=true in the .env file to prevent container cleanup between hot reloads:
TESTCONTAINERS_RYUK_DISABLED=true
Ryuk is the Testcontainers companion container that removes Docker resources created by Testcontainers. For local development with air, container removal during hot-reloads is unnecessary, so Ryuk is disabled, and the container is given a name for reuse across multiple runs.
Step 5: Retrieving and Injecting the PostgreSQL Connection
With the PostgreSQL service as part of the application, it’s possible to use it in the data access layer. The application has a global configuration variable for the database connection string, defined in config/env.go:
// DB returns the connection string of the database.
DB = getEnv("DB", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
Retrieve the service from the app’s state and use it to connect:
// Add the PostgreSQL service to the app, including custom configuration.
srv, err := setupPostgres(&cfg)
if err != nil {
panic(err)
}
app := fiber.New(cfg)
// Retrieve the PostgreSQL service from the app, using the service key.
postgresSrv := fiber.MustGetService[*testcontainers.ContainerService[*postgres.PostgresContainer]](app.State(), srv.Key())
Here, fiber.MustGetService retrieves a generic service from the State cache, which is then cast to the specific service type: *testcontainers.ContainerService[*postgres.PostgresContainer].
- testcontainers.ContainerService[T] is a generic service wrapping a testcontainers.Container instance, provided by the github.com/gofiber/contrib/testcontainers module.
- *postgres.PostgresContainer is the specific type of the container, representing a PostgreSQL container, provided by the github.com/testcontainers/testcontainers-go/modules/postgres module.
Once we have the postgresSrv service, it can be used to connect to the database. The ContainerService type provides a Container() method that unwraps the container from the service, allowing interaction with the container using APIs from the testcontainers package. The connection string is then passed to the global DB variable for use in the data access layer.
// Retrieve the PostgreSQL service from the app, using the service key.
postgresSrv := fiber.MustGetService[*testcontainers.ContainerService[*postgres.PostgresContainer]](app.State(), srv.Key())
connString, err := postgresSrv.Container().ConnectionString(context.Background())
if err != nil {
panic(err)
}
// Override the default database connection string with the one from the Testcontainers service.
config.DB = connString
database.Connect(config.DB)
Step 6: Live Reload with Air
To complete the local development experience, add the -tags dev flag to the air command, ensuring the development configuration is used. In .air.conf, include the -tags dev flag:
cmd = "go build -tags dev -o ./todo-api ./main.go"
Step 7: Implementing Graceful Shutdown
Fiber automatically shuts down the application and all its services when stopped. However, air does not pass the correct signal to trigger the shutdown, necessitating manual intervention.
In main.go, listen from a separate goroutine and notify the main thread when an interrupt or termination signal is sent. Add this to the end of the main function:
// Listen from a different goroutine
go func() {
if err := app.Listen(fmt.Sprintf(":%v", config.PORT)); err != nil {
log.Panic(err)
}
}()
quit := make(chan os.Signal, 1) // Create channel to signify a signal being sent
signal.Notify(quit, os.Interrupt, syscall.SIGTERM) // Notify the channel when an interrupt or termination signal is sent
<-quit // Blocks the main thread until an interrupt is received
fmt.Println("Gracefully shutting down...")
err = app.Shutdown()
if err != nil {
log.Panic(err)
}
Ensure air passes the correct signal to trigger the shutdown by adding this to .air.conf:
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = true
With this configuration, air will send an interrupt signal to the application when stopped, enabling a graceful shutdown.
Demonstrating the Setup
Start the application with air, and it will automatically start the PostgreSQL container and handle graceful shutdowns on stopping. Here’s what the logs may look like:
air
`.air.conf` will be deprecated soon, recommend using `.air.toml`.
__ _ ___
/ /\ | | | |_)
/_/--\ |_| |_| \_ v1.61.7, built with Go go1.24.1
mkdir gofiber-services/tmp
watching .
watching app
watching app/dal
watching app/routes
watching app/services
watching app/types
watching config
watching config/database
!exclude tmp
watching utils
watching utils/jwt
watching utils/middleware
watching utils/password
building...
running...
[DATABASE]::CONNECTED
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[89.614ms] [rows:1] SELECT count(*) FROM information_schema.tables WHERE table_schema = CURRENT_SCHEMA() AND table_name = 'users' AND table_type = 'BASE TABLE'
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[31.446ms] [rows:0] CREATE TABLE "users" ("id" bigserial,"created_at" timestamptz,"updated_at" timestamptz,"deleted_at" timestamptz,"name" text,"email" text NOT NULL,"password" text NOT NULL,PRIMARY KEY ("id"))
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[28.312ms] [rows:0] CREATE UNIQUE INDEX IF NOT EXISTS "idx_users_email" ON "users" ("email")
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[28.391ms] [rows:0] CREATE INDEX IF NOT EXISTS "idx_users_deleted_at" ON "users" ("deleted_at")
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[28.920ms] [rows:1] SELECT count(*) FROM information_schema.tables WHERE table_schema = CURRENT_SCHEMA() AND table_name = 'todos' AND table_type = 'BASE TABLE'
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[29.659ms] [rows:0] CREATE TABLE "todos" ("id" bigserial,"created_at" timestamptz,"updated_at" timestamptz,"deleted_at" timestamptz,"task" text NOT NULL,"completed" boolean DEFAULT false,"user" bigint,PRIMARY KEY ("id"),CONSTRAINT "fk_users_todos" FOREIGN KEY ("user") REFERENCES "users"("id"))
2025/05/29 07:33:19 gofiber-services/config/database/database.go:44
[27.900ms] [rows:0] CREATE INDEX IF NOT EXISTS "idx_todos_deleted_at" ON "todos" ("deleted_at")
_______ __
/ ____(_) /_ ___ _____
/ /_ / / __ \/ _ \/ ___/
/ __/ / / /_/ / __/ /
/_/ /_/_.___/\___/_/ v3.0.0-beta.4
--------------------------------------------------
INFO Server started on: http://127.0.0.1:8000 (bound on host 0.0.0.0 and port 8000)
INFO Services: 1
INFO [ RUNNING ] postgres-db (using testcontainers-go)
INFO Total handlers count: 10
INFO Prefork: Disabled
INFO PID: 36210
INFO Total process count: 1
Check the running containers to confirm the PostgreSQL container is active:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8dc70e1124da postgres:16 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 127.0.0.1:32911->5432/tcp postgres-db-todos
Key observations:
- The container name is postgres-db-todos, as specified in the setupPostgres function.
- The container maps the standard PostgreSQL port 5432 to a dynamically assigned host port 32911, a Testcontainers feature to avoid port conflicts when running multiple containers. For more details, refer to the Testcontainers documentation.
Fast Development Loop
If the application is stopped with air, the container is stopped, thanks to the graceful shutdown. Best of all, if air handles reloads and the application is updated, it hot-reloads, and the PostgreSQL container is reused, eliminating the wait for startup.
For a complete example, see the GitHub repository.
Integration Tests
The application includes integration tests for the data access layer located in the app/dal folder. These tests use Testcontainers to create and test the database in isolation. Run the tests with:
# Test command here
In under 10 seconds, a clean database is ready, and the persistence layer is verified to function as expected. Testcontainers enables tests to run alongside the application, each using its own isolated container with random ports.
Conclusion
Fiber v3’s Services abstraction, combined with Testcontainers, offers a streamlined and production-like local development experience. Gone are the days of handwritten scripts and misaligned environments. Developers can rely on Go code that runs uniformly across environments, fostering a “Clone & Run” experience. Testcontainers also provide a unified developer experience for integration testing and local development, allowing for clean and deterministic application testing with real dependencies.
By separating production and local development configurations, the same codebase can support both environments without introducing development-only tools or dependencies into production.
Future Directions
We welcome feedback and encourage you to share your experiences with Fiber v3. Feel free to leave a comment or open an issue in the GitHub repo.
For more Information, Refer to this article.

































