{"id":2259,"date":"2025-07-17T14:38:18","date_gmt":"2025-07-17T14:38:18","guid":{"rendered":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2025\/07\/17\/gofiber-v3-testcontainers-production-like-local-dev-with-air\/"},"modified":"2025-07-17T14:38:18","modified_gmt":"2025-07-17T14:38:18","slug":"gofiber-v3-testcontainers-production-like-local-dev-with-air","status":"publish","type":"post","link":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2025\/07\/17\/gofiber-v3-testcontainers-production-like-local-dev-with-air\/","title":{"rendered":"GoFiber v3 + Testcontainers: Production-like Local Dev with Air"},"content":{"rendered":"<h2 class=\"wp-block-heading\"><strong>Intro<\/strong><\/h2>\n<p>Local development can be challenging when apps rely on external services like databases or queues, leading to brittle scripts and inconsistent environments. Fiber v3 and <a href=\"https:\/\/docs.docker.com\/testcontainers\/\" target=\"_blank\">Testcontainers<\/a> solve this by making real service dependencies part of your app\u2019s lifecycle, fully managed, reproducible, and developer-friendly.<\/p>\n\n<p>With the upcoming v3 release, Fiber is introducing a powerful new abstraction: Services. These provide a standardized way to start and manage backing services like databases, queues, and cloud emulators, enabling you to manage backing services directly as part of your app\u2019s lifecycle, with no extra orchestration required. Even more exciting is the new contrib module that connects Services with Testcontainers, allowing you to spin up real service dependencies in a clean and testable way.<\/p>\n\n<p>In this post, I\u2019ll walk through how to use these new features by building a small Fiber app that uses a PostgreSQL container for persistence, all managed via the new Service interface.<\/p>\n\n<h2 class=\"wp-block-heading\"><strong>TL;DR<\/strong><\/h2>\n<p>Use Fiber v3\u2019s new Services API to manage backing containers.<\/p>\n<p>Integrate with testcontainers-go to start a PostgreSQL container automatically.<\/p>\n<p>Add hot-reloading with air for a fast local dev loop.<\/p>\n<p>Reuse containers during dev by disabling Ryuk and naming them consistently.<\/p>\n<p>Full example here: <a href=\"https:\/\/github.com\/mdelapenya\/testcontainers-go-examples\/tree\/main\/gofiber-services\" target=\"_blank\">GitHub Repo<\/a><\/p>\n\n<h2 class=\"wp-block-heading\"><strong>Local Development, state of the art<\/strong><\/h2>\n<p>This is a blog post about developing in Go, but let\u2019s look at how other major frameworks approach local development, even across different programming languages.<\/p>\n\n<p>In the Java ecosystem, the most important frameworks, such as Spring Boot, Micronaut and Quarkus, have the concept of Development-time services. Let\u2019s look at how other ecosystems handle this concept of services.<\/p>\n\n<p>From <a href=\"https:\/\/docs.spring.io\/spring-boot\/reference\/features\/dev-services.html\" target=\"_blank\">Spring Boot docs<\/a>:<\/p>\n\n<p><em>Development-time services provide external dependencies needed to run the application while developing it. They are only supposed to be used while developing and are disabled when the application is deployed.<\/em><\/p>\n\n<p>Micronaut uses the concept of <a href=\"https:\/\/micronaut-projects.github.io\/micronaut-test-resources\/latest\/guide\/\" target=\"_blank\">Test Resources<\/a>:<\/p>\n\n<p><em>Micronaut Test Resources adds support for managing external resources which are required during development or testing.<\/em><\/p>\n<p><em>For example, an application may need a database to run (say MySQL), but such a database may not be installed on the development machine or you may not want to handle the setup and tear down of the database manually.<\/em><\/p>\n\n<p>And finally, in Quarkus, the concept of <a href=\"https:\/\/es.quarkus.io\/guides\/dev-services\" target=\"_blank\">Dev Services<\/a> is also present.<\/p>\n\n<p><em>Quarkus supports the automatic provisioning of unconfigured services in development and test mode. We refer to this capability as Dev Services.<\/em><\/p>\n\n<p>Back to Go, one of the most popular frameworks, <a href=\"https:\/\/gofiber.io\/\" target=\"_blank\">Fiber<\/a>, has added the concept of Services, including a new contrib module to add support for Testcontainers-backed services.<\/p>\n\n<h2 class=\"wp-block-heading\"><strong>What\u2019s New in Fiber v3?<\/strong><\/h2>\n\n<p>Among all the new features in Fiber v3, we have two main ones that are relevant to this post:<\/p>\n<p><a href=\"https:\/\/docs.gofiber.io\/next\/api\/services\" target=\"_blank\">Services<\/a>: Define and attach external resources (like databases) to your app in a composable way. This new approach ensures external services are automatically started and stopped with your Fiber app.<\/p>\n<p><a href=\"https:\/\/docs.gofiber.io\/contrib\/next\/testcontainers\/\" target=\"_blank\">Contrib module for Testcontainers<\/a>: Start real backing services using Docker containers, managed directly from your app\u2019s lifecycle in a programmable way.<\/p>\n<h2 class=\"wp-block-heading\"><strong>A Simple Fiber App using Testcontainers<\/strong><\/h2>\n\n<p>The application we are going to build is a simple Fiber app that uses a PostgreSQL container for persistence. It\u2019s based on <a href=\"https:\/\/github.com\/gofiber\/recipes\/tree\/master\/todo-app-with-auth-gorm\" target=\"_blank\">todo-app-with-auth-form Fiber recipe<\/a>, but using the new Services API to start a PostgreSQL container, instead of an in-memory SQLite database.<\/p>\n\n<p><strong>Project Structure<\/strong><\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\n.<br \/>\n\u251c\u2500\u2500 app<br \/>\n|    \u251c\u2500\u2500 dal<br \/>\n|    |    \u251c\u2500\u2500 todo.dal.go<br \/>\n|    |    \u251c\u2500\u2500 todo.dal_test.go<br \/>\n|    |    \u251c\u2500\u2500 user.dal.go<br \/>\n|    |    \u2514\u2500\u2500 user.dal_test.go<br \/>\n|    \u251c\u2500\u2500 routes<br \/>\n|    |    \u251c\u2500\u2500 auth.routes.go<br \/>\n|    |    \u2514\u2500\u2500 todo.routes.go<br \/>\n|    \u251c\u2500\u2500 services<br \/>\n|    |    \u251c\u2500\u2500 auth.service.go<br \/>\n|    |    \u2514\u2500\u2500 todo.service.go<br \/>\n|    \u2514\u2500\u2500 types<br \/>\n|         \u251c\u2500\u2500 auth.types.go<br \/>\n|         \u251c\u2500\u2500 todo.types.go<br \/>\n|         \u2514\u2500\u2500 types.go<br \/>\n\u251c\u2500\u2500 config<br \/>\n|    \u251c\u2500\u2500 database<br \/>\n|    |    \u2514\u2500\u2500 database.go<br \/>\n|    \u251c\u2500\u2500 config.go<br \/>\n|    \u251c\u2500\u2500 config_dev.go<br \/>\n|    \u251c\u2500\u2500 env.go<br \/>\n|    \u2514\u2500\u2500 types.go<br \/>\n\u251c\u2500\u2500 utils<br \/>\n|    \u251c\u2500\u2500 jwt<br \/>\n|    |    \u2514\u2500\u2500 jwt.go<br \/>\n|    \u251c\u2500\u2500 middleware<br \/>\n|    |    \u2514\u2500\u2500 authentication.go<br \/>\n|    \u2514\u2500\u2500 password<br \/>\n|         \u2514\u2500\u2500 password.go<br \/>\n\u251c\u2500\u2500 .air.conf<br \/>\n\u251c\u2500\u2500 .env<br \/>\n\u251c\u2500\u2500 main.go<br \/>\n\u2514\u2500\u2500 go.mod<br \/>\n\u2514\u2500\u2500 go.sum\n<\/div>\n<p>This app exposes several endpoints, for \/users and \/todos, and stores data in a PostgreSQL instance started using Testcontainers. Here\u2019s how it\u2019s put together.<\/p>\n\n<p>Since the application is based on a recipe, we\u2019ll skip the details of creating the routes, the services and the data access layer. You can find the complete code in the <a href=\"https:\/\/github.com\/gofiber\/recipes\/tree\/master\/todo-app-with-auth-gorm\" target=\"_blank\">GitHub repository<\/a>.<\/p>\n\n<p>I\u2019ll instead cover the details about how to use Testcontainers to start the PostgreSQL container, and how to use the Services API to manage the lifecycle of the container, so that the data access layer can use it without having to worry about the lifecycle of the container. Furthermore, I\u2019ll cover how to use air to have a fast local development experience, and how to handle the graceful shutdown of the application, separating the configuration for production and local development.<\/p>\n\n<p>In the config package, we have defined three files that will be used to configure the application, depending on a Go build tag. The first one, the config\/types.go file, defines a struct to hold the application configuration and the cleanup functions for the services startup and shutdown.<\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\npackage config\n<p>import (<br \/>\n\t&#8220;context&#8221;<\/p>\n<p>\t&#8220;github.com\/gofiber\/fiber\/v3&#8221;<br \/>\n)<\/p>\n<p>\/\/ AppConfig holds the application configuration and cleanup functions<br \/>\ntype AppConfig struct {<br \/>\n\t\/\/ App is the Fiber app instance.<br \/>\n\tApp *fiber.App<br \/>\n\t\/\/ StartupCancel is the context cancel function for the services startup.<br \/>\n\tStartupCancel context.CancelFunc<br \/>\n\t\/\/ ShutdownCancel is the context cancel function for the services shutdown.<br \/>\n\tShutdownCancel context.CancelFunc<br \/>\n}\n<\/p><\/div>\n<p>The config.go file has the configuration for production environments:<\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\n\/\/go:build !dev\n<p>package config<\/p>\n<p>import (<br \/>\n\t&#8220;github.com\/gofiber\/fiber\/v3&#8221;<br \/>\n)<\/p>\n<p>\/\/ ConfigureApp configures the fiber app, including the database connection string.<br \/>\n\/\/ The connection string is retrieved from the environment variable DB, or using<br \/>\n\/\/ falls back to a default connection string targeting localhost if DB is not set.<br \/>\nfunc ConfigureApp(cfg fiber.Config) (*AppConfig, error) {<br \/>\n\tapp := fiber.New(cfg)<\/p>\n<p>\tdb := getEnv(&#8220;DB&#8221;, &#8220;postgres:\/\/postgres:postgres@localhost:5432\/postgres?sslmode=disable&#8221;)<br \/>\n\tDB = db<\/p>\n<p>\treturn &amp;AppConfig{<br \/>\n\t\tApp:            app,<br \/>\n\t\tStartupCancel:  func() {}, \/\/ No-op for production<br \/>\n\t\tShutdownCancel: func() {}, \/\/ No-op for production<br \/>\n\t}, nil<br \/>\n}\n<\/p><\/div>\n<p>The ConfigureApp function is responsible for creating the Fiber app, and it\u2019s used in the main.go file to initialize the application. By default, it will try to connect to a PostgreSQL instance, using the DB environment variable, falling back to a local PostgreSQL instance if the environment variable is not set. It also uses empty functions for the StartupCancel and ShutdownCancel fields, as we don\u2019t need to cancel anything in production.<\/p>\n\n<p>When running the app with go run main.go, the !dev tag applies by default, and the ConfigureApp function will be used to initialize the application. But the application will not start, as the connection to the PostgreSQL instance will fail.<\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\ngo run main.go\n<p>2025\/05\/29 11:55:36 gofiber-services\/config\/database\/database.go:18<br \/>\n[error] failed to initialize database, got error failed to connect to `user=postgres database=postgres`:<br \/>\n       [::1]:5432 (localhost): dial error: dial tcp [::1]:5432: connect: connection refused<br \/>\n       127.0.0.1:5432 (localhost): dial error: dial tcp 127.0.0.1:5432: connect: connection refused<br \/>\npanic: gorm open: failed to connect to `user=postgres database=postgres`:<br \/>\n               [::1]:5432 (localhost): dial error: dial tcp [::1]:5432: connect: connection refused<br \/>\n               127.0.0.1:5432 (localhost): dial error: dial tcp 127.0.0.1:5432: connect: connection refused<\/p>\n<p>goroutine 1 [running]:<br \/>\ngofiber-services\/config\/database.Connect({0x105164a30?, 0x0?})<br \/>\n       gofiber-services\/config\/database\/database.go:33 +0x9c<br \/>\nmain.main()<br \/>\n       gofiber-services\/main.go:34 +0xbc<br \/>\nexit status 2\n<\/p><\/div>\n<p>Let\u2019s fix that!<\/p>\n<p><strong>Step 1: Add the dependencies<\/strong><\/p>\n\n<p>First, we need to make sure we have the dependencies added to the go.mod file:<\/p>\n\n<p><em>Note: Fiber v3 is still in development. To use Services, you\u2019ll need to pull the main branch from GitHub:<\/em><\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\ngo get github.com\/gofiber\/fiber\/v3@main<br \/>\ngo get github.com\/gofiber\/contrib\/testcontainers<br \/>\ngo get github.com\/testcontainers\/testcontainers-go<br \/>\ngo get github.com\/testcontainers\/testcontainers-go\/modules\/postgres<br \/>\ngo get gorm.io\/driver\/postgres\n<\/div>\n\n<p><strong>Step 2: Define a PostgreSQL Service using Testcontainers<\/strong><\/p>\n\n<p>To leverage the new Services API, we need to define a new service. We can implement the interface exposed by the Fiber app, as shown in the <a href=\"https:\/\/docs.gofiber.io\/next\/api\/services#example-adding-a-service\" target=\"_blank\">Services API docs<\/a>, or simply use the Testcontainers contrib module to create a new service, as we are going to do next.<\/p>\n\n<p>In the config\/config_dev.go file, we define a new function to add a PostgreSQL container as a service to the Fiber application, using the Testcontainers contrib module. This file is using the dev build tag, so it will only be used when we start the application with air.<\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\n\/\/go:build dev\n<p>package config<\/p>\n<p>import (<br \/>\n\t&#8220;fmt&#8221;<\/p>\n<p>\t&#8220;github.com\/gofiber\/contrib\/testcontainers&#8221;<br \/>\n\t&#8220;github.com\/gofiber\/fiber\/v3&#8221;<br \/>\n\ttc &#8220;github.com\/testcontainers\/testcontainers-go&#8221;<br \/>\n\t&#8220;github.com\/testcontainers\/testcontainers-go\/modules\/postgres&#8221;<br \/>\n)<\/p>\n<p>\/\/ setupPostgres adds a Postgres service to the app, including custom configuration to allow<br \/>\n\/\/ reusing the same container while developing locally.<br \/>\nfunc setupPostgres(cfg *fiber.Config) (*testcontainers.ContainerService[*postgres.PostgresContainer], error) {<br \/>\n\t\/\/ Add the Postgres service to the app, including custom configuration.<br \/>\n\tsrv, err := testcontainers.AddService(cfg, testcontainers.NewModuleConfig(<br \/>\n\t\t&#8220;postgres-db&#8221;,<br \/>\n\t\t&#8220;postgres:16&#8221;,<br \/>\n\t\tpostgres.Run,<br \/>\n\t\tpostgres.BasicWaitStrategies(),<br \/>\n\t\tpostgres.WithDatabase(&#8220;todos&#8221;),<br \/>\n\t\tpostgres.WithUsername(&#8220;postgres&#8221;),<br \/>\n\t\tpostgres.WithPassword(&#8220;postgres&#8221;),<br \/>\n\t\ttc.WithReuseByName(&#8220;postgres-db-todos&#8221;),<br \/>\n\t))<br \/>\n\tif err != nil {<br \/>\n\t\treturn nil, fmt.Errorf(&#8220;add postgres service: %w&#8221;, err)<br \/>\n\t}<\/p>\n<p>\treturn srv, nil<br \/>\n}\n<\/p><\/div>\n<p>This creates a reusable Service that Fiber will automatically start and stop along with the app, and it\u2019s 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. To learn more about the PostgreSQL module, please refer to the <a href=\"https:\/\/golang.testcontainers.org\/modules\/postgres\/\" target=\"_blank\">Testcontainers PostgreSQL module documentation<\/a>.<\/p>\n\n<p><strong>Step 3: Initialize the Fiber App with the PostgreSQL Service<\/strong><\/p>\n\n<p>Our fiber.App is initialized in the config\/config.go file, using the ConfigureApp function for production environments. For local development, instead, we need to initialize the fiber.App in the config\/config_dev.go file, using a function with the same signature, but using the contrib module to add the PostgreSQL service to the app config.<\/p>\n\n<p>We need to define a context provider for the services startup and shutdown, and add the PostgreSQL service to the app config, including custom configuration. The context provider is useful to define a cancel policy for the services startup and shutdown, so we can cancel the startup or shutdown if the context is canceled. If no context provider is defined, the default is to use the context.Background().<\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\n\/\/ ConfigureApp configures the fiber app, including the database connection string.<br \/>\n\/\/ The connection string is retrieved from the PostgreSQL service.<br \/>\nfunc ConfigureApp(cfg fiber.Config) (*AppConfig, error) {<br \/>\n\t\/\/ Define a context provider for the services startup.<br \/>\n\t\/\/ The timeout is applied when the context is actually used during startup.<br \/>\n\tstartupCtx, startupCancel := context.WithCancel(context.Background())<br \/>\n\tvar startupTimeoutCancel context.CancelFunc<br \/>\n\tcfg.ServicesStartupContextProvider = func() context.Context {<br \/>\n\t\t\/\/ Cancel any previous timeout context<br \/>\n\t\tif startupTimeoutCancel != nil {<br \/>\n\t\t\tstartupTimeoutCancel()<br \/>\n\t\t}<br \/>\n\t\t\/\/ Create a new timeout context<br \/>\n\t\tctx, cancel := context.WithTimeout(startupCtx, 10*time.Second)<br \/>\n\t\tstartupTimeoutCancel = cancel<br \/>\n\t\treturn ctx<br \/>\n\t}\n<p>\t\/\/ Define a context provider for the services shutdown.<br \/>\n\t\/\/ The timeout is applied when the context is actually used during shutdown.<br \/>\n\tshutdownCtx, shutdownCancel := context.WithCancel(context.Background())<br \/>\n\tvar shutdownTimeoutCancel context.CancelFunc<br \/>\n\tcfg.ServicesShutdownContextProvider = func() context.Context {<br \/>\n\t\t\/\/ Cancel any previous timeout context<br \/>\n\t\tif shutdownTimeoutCancel != nil {<br \/>\n\t\t\tshutdownTimeoutCancel()<br \/>\n\t\t}<br \/>\n\t\t\/\/ Create a new timeout context<br \/>\n\t\tctx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)<br \/>\n\t\tshutdownTimeoutCancel = cancel<br \/>\n\t\treturn ctx<br \/>\n\t}<\/p>\n<p>\t\/\/ Add the Postgres service to the app, including custom configuration.<br \/>\n\tsrv, err := setupPostgres(&amp;cfg)<br \/>\n\tif err != nil {<br \/>\n\t\tif startupTimeoutCancel != nil {<br \/>\n\t\t\tstartupTimeoutCancel()<br \/>\n\t\t}<br \/>\n\t\tif shutdownTimeoutCancel != nil {<br \/>\n\t\t\tshutdownTimeoutCancel()<br \/>\n\t\t}<br \/>\n\t\tstartupCancel()<br \/>\n\t\tshutdownCancel()<br \/>\n\t\treturn nil, fmt.Errorf(&#8220;add postgres service: %w&#8221;, err)<br \/>\n\t}<\/p>\n<p>\tapp := fiber.New(cfg)<\/p>\n<p>\t\/\/ Retrieve the Postgres service from the app, using the service key.<br \/>\n\tpostgresSrv := fiber.MustGetService[*testcontainers.ContainerService[*postgres.PostgresContainer]](app.State(), srv.Key())<\/p>\n<p>\tconnString, err := postgresSrv.Container().ConnectionString(context.Background())<br \/>\n\tif err != nil {<br \/>\n\t\tif startupTimeoutCancel != nil {<br \/>\n\t\t\tstartupTimeoutCancel()<br \/>\n\t\t}<br \/>\n\t\tif shutdownTimeoutCancel != nil {<br \/>\n\t\t\tshutdownTimeoutCancel()<br \/>\n\t\t}<br \/>\n\t\tstartupCancel()<br \/>\n\t\tshutdownCancel()<br \/>\n\t\treturn nil, fmt.Errorf(&#8220;get postgres connection string: %w&#8221;, err)<br \/>\n\t}<\/p>\n<p>\t\/\/ Override the default database connection string with the one from the Testcontainers service.<br \/>\n\tDB = connString<\/p>\n<p>\treturn &amp;AppConfig{<br \/>\n\t\tApp: app,<br \/>\n\t\tStartupCancel: func() {<br \/>\n\t\t\tif startupTimeoutCancel != nil {<br \/>\n\t\t\t\tstartupTimeoutCancel()<br \/>\n\t\t\t}<br \/>\n\t\t\tstartupCancel()<br \/>\n\t\t},<br \/>\n\t\tShutdownCancel: func() {<br \/>\n\t\t\tif shutdownTimeoutCancel != nil {<br \/>\n\t\t\t\tshutdownTimeoutCancel()<br \/>\n\t\t\t}<br \/>\n\t\t\tshutdownCancel()<br \/>\n\t\t},<br \/>\n\t}, nil<br \/>\n}\n<\/p><\/div>\n<p>This function:<\/p>\n<p>Defines a context provider for the services startup and shutdown, defining a timeout for the startup and shutdown when the context is actually used during startup and shutdown.<\/p>\n<p>Adds the PostgreSQL service to the app config.<\/p>\n<p>Retrieves the PostgreSQL service from the app\u2019s state cache.<\/p>\n<p>Uses the PostgreSQL service to obtain the connection string.<\/p>\n<p>Overrides the default database connection string with the one from the Testcontainers service.<\/p>\n<p>Returns the app config.<\/p>\n<p>As a result, the fiber.App will be initialized with the PostgreSQL service, and it will be automatically started and stopped along with the app. The service representing the PostgreSQL container will be available as part of the application State, which we can easily retrieve from the app\u2019s state cache. Please refer to the <a href=\"https:\/\/docs.gofiber.io\/next\/api\/state\" target=\"_blank\">State Management docs<\/a> for more details about how to use the State cache.<\/p>\n\n<p><strong>Step 4: Optimizing Local Dev with Container Reuse<\/strong><\/p>\n\n<p>Please note that, in the config\/config_dev.go file, the tc.WithReuseByName option is used to reuse the same container while developing locally. This is useful to avoid having to wait for the database to be ready when the application is started.<\/p>\n\n<p>Also, set TESTCONTAINERS_RYUK_DISABLED=true to prevent container cleanup between hot reloads. In the .env file, add the following:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\nTESTCONTAINERS_RYUK_DISABLED=true\n<\/div>\n<p><a href=\"https:\/\/golang.testcontainers.org\/features\/garbage_collector\/#ryuk\" target=\"_blank\">Ryuk<\/a> is the Testcontainers companion container that removes the Docker resources created by Testcontainers. For our use case, where we want to develop locally using air, we don\u2019t want to remove the container when the application is hot-reloaded, so we disable Ryuk and give the container a name that will be reused across multiple runs of the application.<\/p>\n\n<p><strong>Step 5: Retrieve and Inject the PostgreSQL Connection<\/strong><\/p>\n\n<p>Now that the PostgreSQL service is part of the application, we can use it in our data access layer. The application has a global configuration variable that includes the database connection string, in the config\/env.go file:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\n     \/\/ DB returns the connection string of the database.<br \/>\n\tDB = getEnv(&#8220;DB&#8221;, &#8220;postgres:\/\/postgres:postgres@localhost:5432\/postgres?sslmode=disable&#8221;)\n<\/div>\n<p>Retrieve the service from the app\u2019s state and use it to connect:<\/p>\n<div class=\"wp-block-syntaxhighlighter-code \">\n     \/\/ Add the PostgreSQL service to the app, including custom configuration.<br \/>\n\tsrv, err := setupPostgres(&amp;cfg)<br \/>\n\tif err != nil {<br \/>\n\t\tpanic(err)<br \/>\n\t}\n<p>\tapp := fiber.New(cfg)<\/p>\n<p>\t\/\/ Retrieve the PostgreSQL service from the app, using the service key.<br \/>\n\tpostgresSrv := fiber.MustGetService[*testcontainers.ContainerService[*postgres.PostgresContainer]](app.State(), srv.Key())\n<\/p><\/div>\n\n<p>Here, the fiber.MustGetService function is used to retrieve a generic service from the State cache, and we need to cast it to the specific service type, in this case *testcontainers.ContainerService[*postgres.PostgresContainer].<\/p>\n<p>testcontainers.ContainerService[T] is a generic service that wraps a testcontainers.Container instance. It\u2019s provided by the github.com\/gofiber\/contrib\/testcontainers module.<\/p>\n<p>*postgres.PostgresContainer is the specific type of the container, in this case a PostgreSQL container. It\u2019s provided by the github.com\/testcontainers\/testcontainers-go\/modules\/postgres module.<\/p>\n<p>Once we have the postgresSrv service, we can use it to connect to the database. The ContainerService type provides a Container() method that unwraps the container from the service, so we are able to use the APIs provided by the testcontainers package to interact with the container. Finally, we pass the connection string to the global DB variable, so the data access layer can use it to connect to the database.<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\n\/\/ Retrieve the PostgreSQL service from the app, using the service key.<br \/>\n\tpostgresSrv := fiber.MustGetService[*testcontainers.ContainerService[*postgres.PostgresContainer]](app.State(), srv.Key())\n<p>\tconnString, err := postgresSrv.Container().ConnectionString(context.Background())<br \/>\n\tif err != nil {<br \/>\n\t\tpanic(err)<br \/>\n\t}<\/p>\n<p>     \/\/ Override the default database connection string with the one from the Testcontainers service.<br \/>\n\tconfig.DB = connString<\/p>\n<p>\tdatabase.Connect(config.DB)\n<\/p><\/div>\n\n<p><strong>Step 6: Live reload with air<\/strong><\/p>\n\n<p>Let\u2019s add the build tag to the air command, so our local development experience is complete. We need to add the -tags dev flag to the command used to build the application. In .air.conf, add the -tags dev flag to ensure the development configuration is used:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\ncmd = &#8220;go build -tags dev -o .\/todo-api .\/main.go&#8221;\n<\/div>\n<p><strong>Step 7: Graceful Shutdown<\/strong><\/p>\n\n<p>Fiber automatically shuts down the application and all its services when the application is stopped. But air is not passing the right signal to the application to trigger the shutdown, so we need to do it manually.<\/p>\n\n<p>In main.go, we need to listen from a different goroutine, and we need to notify the main thread when an interrupt or termination signal is sent. Let\u2019s add this to the end of the main function:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\n\/\/ Listen from a different goroutine<br \/>\n\tgo func() {<br \/>\n\t\tif err := app.Listen(fmt.Sprintf(&#8220;:%v&#8221;, config.PORT)); err != nil {<br \/>\n\t\t\tlog.Panic(err)<br \/>\n\t\t}<br \/>\n\t}()\n<p>\tquit := make(chan os.Signal, 1)                    \/\/ Create channel to signify a signal being sent<br \/>\n\tsignal.Notify(quit, os.Interrupt, syscall.SIGTERM) \/\/ When an interrupt or termination signal is sent, notify the channel<\/p>\n<p>\t&lt;-quit \/\/ This blocks the main thread until an interrupt is received<br \/>\n\tfmt.Println(&#8220;Gracefully shutting down&#8230;&#8221;)<br \/>\n\terr = app.Shutdown()<br \/>\n\tif err != nil {<br \/>\n\t\tlog.Panic(err)<br \/>\n\t}\n<\/p><\/div>\n\n<p>And we need to make sure air is passing the right signal to the application to trigger the shutdown. Add this to .air.conf to make it work:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\n# Send Interrupt signal before killing process (windows does not support this feature)<br \/>\nsend_interrupt = true\n<\/div>\n\n<p>With this, air will send an interrupt signal to the application when the application is stopped, so we can trigger the graceful shutdown when we stop the application with air.<\/p>\n\n<h2 class=\"wp-block-heading\"><strong>Seeing it in action<\/strong><\/h2>\n\n<p>Now, we can start the application with air, and it will start the PostgreSQL container automatically, and it will handle the graceful shutdown when we stop the application. Let\u2019s see it in action!<\/p>\n\n<p>Let\u2019s start the application with air. You should see output like this in the logs:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\nair\n<p>`.air.conf` will be deprecated soon, recommend using `.air.toml`.<\/p>\n<p> __    _   ___<br \/>\n\/ \/ | | | |_)<br \/>\n\/_\/&#8211; |_| |_| _ v1.61.7, built with Go go1.24.1<\/p>\n<p>mkdir gofiber-services\/tmp<br \/>\nwatching .<br \/>\nwatching app<br \/>\nwatching app\/dal<br \/>\nwatching app\/routes<br \/>\nwatching app\/services<br \/>\nwatching app\/types<br \/>\nwatching config<br \/>\nwatching config\/database<br \/>\n!exclude tmp<br \/>\nwatching utils<br \/>\nwatching utils\/jwt<br \/>\nwatching utils\/middleware<br \/>\nwatching utils\/password<br \/>\nbuilding&#8230;<br \/>\nrunning&#8230;<br \/>\n[DATABASE]::CONNECTED<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<br \/>\n[89.614ms] [rows:1] SELECT count(*) FROM information_schema.tables WHERE table_schema = CURRENT_SCHEMA() AND table_name = &#8216;users&#8217; AND table_type = &#8216;BASE TABLE&#8217;<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<\/p>\n<p>[31.446ms] [rows:0] CREATE TABLE &#8220;users&#8221; (&#8220;id&#8221; bigserial,&#8221;created_at&#8221; timestamptz,&#8221;updated_at&#8221; timestamptz,&#8221;deleted_at&#8221; timestamptz,&#8221;name&#8221; text,&#8221;email&#8221; text NOT NULL,&#8221;password&#8221; text NOT NULL,PRIMARY KEY (&#8220;id&#8221;))<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<br \/>\n[28.312ms] [rows:0] CREATE UNIQUE INDEX IF NOT EXISTS &#8220;idx_users_email&#8221; ON &#8220;users&#8221; (&#8220;email&#8221;)<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<br \/>\n[28.391ms] [rows:0] CREATE INDEX IF NOT EXISTS &#8220;idx_users_deleted_at&#8221; ON &#8220;users&#8221; (&#8220;deleted_at&#8221;)<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<br \/>\n[28.920ms] [rows:1] SELECT count(*) FROM information_schema.tables WHERE table_schema = CURRENT_SCHEMA() AND table_name = &#8216;todos&#8217; AND table_type = &#8216;BASE TABLE&#8217;<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<br \/>\n[29.659ms] [rows:0] CREATE TABLE &#8220;todos&#8221; (&#8220;id&#8221; bigserial,&#8221;created_at&#8221; timestamptz,&#8221;updated_at&#8221; timestamptz,&#8221;deleted_at&#8221; timestamptz,&#8221;task&#8221; text NOT NULL,&#8221;completed&#8221; boolean DEFAULT false,&#8221;user&#8221; bigint,PRIMARY KEY (&#8220;id&#8221;),CONSTRAINT &#8220;fk_users_todos&#8221; FOREIGN KEY (&#8220;user&#8221;) REFERENCES &#8220;users&#8221;(&#8220;id&#8221;))<\/p>\n<p>2025\/05\/29 07:33:19 gofiber-services\/config\/database\/database.go:44<br \/>\n[27.900ms] [rows:0] CREATE INDEX IF NOT EXISTS &#8220;idx_todos_deleted_at&#8221; ON &#8220;todos&#8221; (&#8220;deleted_at&#8221;)<\/p>\n<p>   _______ __<br \/>\n  \/ ____(_) \/_  ___  _____<br \/>\n \/ \/_  \/ \/ __ \/ _ \/ ___\/<br \/>\n\/ __\/ \/ \/ \/_\/ \/  __\/ \/<br \/>\n\/_\/   \/_\/_.___\/___\/_\/          v3.0.0-beta.4<br \/>\n&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8212;&#8211;<br \/>\nINFO Server started on:         http:\/\/127.0.0.1:8000 (bound on host 0.0.0.0 and port 8000)<br \/>\nINFO Services:  1<br \/>\nINFO    [ RUNNING ] postgres-db (using testcontainers-go)<br \/>\nINFO Total handlers count:      10<br \/>\nINFO Prefork:                   Disabled<br \/>\nINFO PID:                       36210<br \/>\nINFO Total process count:       1\n<\/p><\/div>\n\n<p>If we open a terminal and check the running containers, we see the PostgreSQL container is running:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\ndocker ps<br \/>\nCONTAINER ID   IMAGE         COMMAND                  CREATED         STATUS         PORTS                       NAMES<br \/>\n8dc70e1124da   postgres:16   &#8220;docker-entrypoint.s\u2026&#8221;   2 minutes ago   Up 2 minutes   127.0.0.1:32911-&gt;5432\/tcp   postgres-db-todos\n<\/div>\n\n<p>Notice two important things:<\/p>\n\n<p>the container name is postgres-db-todos, that\u2019s the name we gave to the container in the setupPostgres function.<\/p>\n<p>the container is mapping the standard PostgreSQL port 5432 to a dynamically assigned host port 32911 in the host. This is a Testcontainers feature to avoid port conflicts when running multiple containers of the same type, making the execution deterministic and reliable. To learn more about this, please refer to the <a href=\"https:\/\/golang.testcontainers.org\/features\/networking\/#exposing-container-ports-to-the-host\" target=\"_blank\">Testcontainers documentation<\/a>.<\/p>\n<p><strong>Fast Dev Loop<\/strong><\/p>\n\n<p>If we now stop the application with air, we see the container is stopped, thanks to the graceful shutdown implemented in the application.<\/p>\n\n<p>But, best of all, if you let air handle reloads, and you update the application, air will hot-reload the application, and the PostgreSQL container will be reused, so we do not need to wait for it to be started! Sweet!<\/p>\n\n<p>Check out the full example in the <a href=\"https:\/\/github.com\/mdelapenya\/testcontainers-go-examples\/tree\/main\/gofiber-services\" target=\"_blank\">GitHub repository<\/a>.<\/p>\n\n<p><strong>Integration Tests<\/strong><\/p>\n\n<p>The application includes integration tests for the data access layer, in the app\/dal folder. They use <a href=\"https:\/\/golang.testcontainers.org\/\" target=\"_blank\">Testcontainers<\/a> to create the database and test it in isolation! Run the tests with:<\/p>\n\n<div class=\"wp-block-syntaxhighlighter-code \">\ngo test -v .\/app\/dal\n<\/div>\n<p>In less than 10 seconds, we have a clean database and our persistence layer is verified to behave as expected!<\/p>\n\n<p>Thanks to Testcontainers, tests can run alongside the application, each using its own isolated container with random ports.<\/p>\n\n<h2 class=\"wp-block-heading\"><strong>Conclusion<\/strong><\/h2>\n\n<p>Fiber v3\u2019s Services abstraction combined with Testcontainers unlocks a simple, production-like local dev experience. No more hand-crafted scripts, no more out-of-sync environments \u2014 just Go code that runs clean everywhere, providing a \u201cClone &amp; Run\u201d experience. Besides that, using Testcontainers offers a unified developer experience for both integration testing and local development, a great way to test your application cleanly and deterministically\u2014with real dependencies.<\/p>\n\n<p>Because we\u2019ve separated configuration for production and local development, the same codebase can cleanly support both environments\u2014without polluting production with development-only tools or dependencies.<\/p>\n\n<h2 class=\"wp-block-heading\"><strong>What\u2019s next?<\/strong><\/h2>\n\n<p>Check the different testcontainers modules in the <a href=\"https:\/\/testcontainers.com\/modules?language=go\" target=\"_blank\">Testcontainers Modules Catalog<\/a>.<\/p>\n<p>Check the <a href=\"https:\/\/github.com\/testcontainers\/testcontainers-go\" target=\"_blank\">Testcontainers Go<\/a> repository for more information about the Testcontainers Go library.<\/p>\n<p>Try <a href=\"https:\/\/testcontainers.com\/cloud\" target=\"_blank\">Testcontainers Cloud<\/a> to run the Service containers in a reliable manner, locally and in your CI.<\/p>\n<p>Have feedback or want to share how you\u2019re using Fiber v3? Drop a comment or open an issue in the <a href=\"https:\/\/github.com\/mdelapenya\/testcontainers-go-examples\" target=\"_blank\">GitHub repo<\/a>!<\/p>","protected":false},"excerpt":{"rendered":"<p>Intro Local development can be challenging when apps rely on external services like databases or queues, leading to brittle scripts [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":0,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[4],"tags":[],"class_list":["post-2259","post","type-post","status-publish","format-standard","hentry","category-docker"],"_links":{"self":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/2259","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/comments?post=2259"}],"version-history":[{"count":0,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/2259\/revisions"}],"wp:attachment":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media?parent=2259"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/categories?post=2259"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/tags?post=2259"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}